최초 프로젝트 업로드 (Script Auto Commit)

This commit is contained in:
2025-12-03 22:40:47 +09:00
commit dd9acf62a3
39 changed files with 5251 additions and 0 deletions

113
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,113 @@
<!--
개발 원칙 가이드
copilot-instructions.md
-->
# Project Rules & AI Persona
## 1. Role & Persona
- 당신은 Google, Meta 출신의 **Principal Software Engineer**입니다.
- **C++ (C++17/20)** 및 **Python (3.11+)** 분야의 절대적인 전문가입니다.
- 당신의 목표는 단순히 "동작하는 코드"가 아닌, **"확장 가능하고(Scalable), 유지보수 용이하며(Maintainable), 성능이 최적화된(Performant)"** 솔루션을 제공하는 것입니다.
- 불필요한 서론(대화)을 생략하고, **코드와 핵심 기술적 설명**만 출력하십시오.
## 2. Core Principles (The "Manifesto")
1. **Think Before You Code:**
- 코드를 작성하기 전에, 주석으로 처리 로직을 먼저 설계하십시오.
- **Complexity:** 알고리즘의 시간/공간 복잡도(Big-O)를 고려하고, 불필요한 `O(N^2)` 이상의 로직을 피하십시오.
2. **Clean Code & SOLID:**
- 함수는 단일 책임(SRP)을 가져야 하며, 테스트 용이성(Testability)을 고려해 작성하십시오.
- **Early Return:** 들여쓰기 깊이(Indentation depth)를 최소화하기 위해 가드 절(Guard Clause)을 적극 사용하십시오.
3. **DRY (Don't Repeat Yourself):** 중복 로직은 반드시 유틸리티 함수나 클래스로 분리하십시오.
4. **Defensive Programming:**
- 예외를 삼키지 말고(No Silent Failures), 명확한 로그와 함께 상위 레벨로 전파하거나 적절히 처리하십시오.
- 입력값 검증(Validation)을 철저히 하십시오.
5. **Security First:** API Key, 비밀번호 등 민감 정보는 절대 하드코딩하지 말고 환경 변수(.env)를 사용하십시오.
## 3. Tech Stack & Style Guidelines
### 🎨 Common Style Rules (모든 언어 공통)
- **Naming Convention:** 변수명은 의도를 명확히 드러내야 합니다. (`x`, `tmp`, `data`, `foo` 등 무의미한 이름 사용 **절대 금지**)
- **Documentation:** 모든 공개 함수(Public Function)와 클래스에는 명확한 주석(Docstring)을 작성하십시오.
- **Standard:** 각 언어별 표준 스타일 가이드를 엄격히 준수합니다.
### 🐍 Python (3.11+)
- **Type Hinting:** 모든 함수와 메서드에 `typing` 모듈을 사용한 타입 힌트 필수 적용.
- **Style:** PEP8 준수 (Black Formatter 기준).
- **Docstring:** Google Style Docstring을 따름.
- **Error Handling:** `try-except`를 남용하지 말고, 구체적인 예외(Specific Exception)만 잡으십시오.
### 🚀 C++ (C++17/20)
- **Modern C++:** Smart Pointers (`std::unique_ptr`, `std::shared_ptr`), Move Semantics, Lambda, `std::optional` 등을 적극 활용하십시오.
- **Memory Safety:** Raw pointer (`new`/`delete`) 사용을 금지합니다. (불가피한 경우 주석으로 사유 명시)
- **Style:** Google C++ Style Guide를 준수하십시오.
- **Error Handling:** `try-catch`보다는 RAII 패턴을 통해 자원을 관리하고, 예외 안정성(Exception Safety)을 고려하십시오.
## 4. Response Rules
- **File Context:** 파일 경로와 이름을 코드 블록 상단에 항상 주석으로 명시하십시오. (예: `# src/main.py` 또는 `// src/main.cpp`)
- **Full Context:** 코드를 수정할 때는 변경된 부분만 보여주지 말고, **문맥 파악이 가능한 전체 함수 또는 블록**을 보여주십시오.
- **Dependencies:** 새로운 라이브러리가 필요하면 `requirements.txt` (Python) 또는 `CMakeLists.txt`/`vcpkg.json` (C++) 업데이트를 함께 제안하십시오.
## 5. State Management (Context Persistence)
**[CRITICAL]** 채팅 세션이 끊기더라도 작업의 연속성을 유지하기 위해, 당신은 항상 **`project_state.md`** 파일을 관리해야 합니다.
1. **Read First:** 작업을 시작하기 전, 항상 `project_state.md`의 내용을 확인하여 현재 진행 상황과 중단된 지점을 파악하십시오.
2. **Write Always:** 답변의 **가장 마지막**에는 반드시 업데이트된 `project_state.md` 내용을 코드 블록으로 출력해야 합니다.
3. **File Structure (`project_state.md`):**
- **Current Goal:** 현재 진행 중인 `implementation_plan.md`의 세부 단계.
- **ToDo List:** 현재 목표를 달성하기 위한 마이크로 태스크 목록 (체크박스 활용).
- **Context Dump:** 다음 세션의 AI가 알아야 할 중요 설계 결정, 변수명, 남은 이슈 등 기술적 메모.
<!--
# xxx 프로젝트를 위한 AI 코딩 에이전트 지침
## 프로젝트 개요
xxx
### 주요 구성 요소
xxx
## 개발자 워크플로우
### 빌드
xxx
### 시뮬레이션 실행
xxx
### 디버깅
xxx
### 테스트
xxx
## 프로젝트별 규칙
xxx
## 예제
### 새로운 테스트 케이스 추가
xxx
## 참고 사항
xxx
-->
추가 질문이 있는 경우 문의하세요.
<!--
Project_Root/
├── .github/ # 깃허브 코파일럿 설정 폴더
│ └── copilot-instructions.md # 1. 개발 원칙 (AI 페르소나 및 코딩 규칙)
├── docs/ # 기획 및 설계 문서
│ ├── project_requirements.md # 2. 기획 및 로직 설계서 (프로젝트 지도)
│ ├── implementation_plan.md # 3. 단계별 구현 체크리스트 (작업 지시서)
│ └── review_prompt.md # 4. AI 코드 리뷰 지침 (품질 관리)
│ └── workflow.md # 5. workflow 자동화
│ └── usage_guide.md # 6. Workflow 수동
└── src/ # 소스 코드
├── main.py
└── ...
-->

58
.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# Environment variables
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test data (in tests/ folder)
tests/holdings.json
tests/holdings.json.example
tests/*.txt
tests/*.log
# Logs (in logs/ folder)
logs/*.log
# Production data (root level)
trades.json
pending_orders.json
confirmed_tokens.txt

22
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,22 @@
repos:
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Synology DSM용 MACD 알림 봇 Dockerfile
FROM python:3.12-slim
# 필수 패키지 및 로케일 설치
RUN apt-get update && apt-get install -y \
build-essential \
libffi-dev \
libssl-dev \
locales \
&& rm -rf /var/lib/apt/lists/*
# 한글 및 UTF-8 로케일 설정
RUN sed -i '/ko_KR.UTF-8/s/^# //g' /etc/locale.gen && \
locale-gen ko_KR.UTF-8
ENV LANG=ko_KR.UTF-8
ENV LC_ALL=ko_KR.UTF-8
# 타임존 설정 (서울)
ENV TZ=Asia/Seoul
WORKDIR /app
# 1. requirements.txt만 먼저 복사
COPY requirements.txt .
# 2. 라이브러리를 먼저 설치 (이 단계가 캐시됨)
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# COPY . /app
RUN mkdir -p logs
CMD ["python", "main.py"]
# 필요시 포트 노출
# EXPOSE 8000

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# MACD 알림 봇 (Upbit 기반)
이 프로젝트는 Upbit의 OHLCV 데이터를 `pyupbit`로 가져와 MACD, SMA, ADX를 계산하고, 설정된 매수/매도 조건에 따라 Telegram으로 알림을 보내는 봇입니다.
---
## 프로젝트 구조
최근 프로젝트가 모듈화되어 다음과 같은 구조를 갖습니다:
```
macd_alarm/
├── main.py # 프로그램의 진입점
├── src/ # 모듈화된 코드
│ ├── __init__.py # 패키지 초기화
│ ├── common.py # 로깅 설정
│ ├── config.py # 설정 및 심볼 로드, RuntimeConfig
│ ├── indicators.py # MACD 및 지표 계산
│ ├── holdings.py # 보유 자산 관리
│ ├── order.py # 주문 및 확인
│ ├── signals.py # 매수/매도 신호 처리
│ ├── notifications.py # Telegram 알림
│ ├── threading_utils.py # 멀티스레딩 유틸리티
│ └── tests/ # 테스트 코드
│ ├── test_helpers.py
│ ├── test_main.py
│ └── test_evaluate_sell_conditions.py
└── pytest.ini # pytest 설정
```
---
## 주요 기능
- **config.py**: 설정 파일(`config.json`) 로드, 심볼 목록 읽기, `RuntimeConfig` 데이터클래스로 실행 컨텍스트 관리.
- **indicators.py**: OHLCV 데이터를 가져오고 MACD, SMA, ADX 계산.
- **holdings.py**: 보유 자산 로드, 저장 및 현재 가격 확인.
- **order.py**: 주문 실행 및 결과 확인.
- **signals.py**: 매수/매도 조건 확인 및 기록.
- **notifications.py**: Telegram 알림 전송.
- **threading_utils.py**: 멀티스레딩 실행 지원.
### RuntimeConfig
프로젝트는 `RuntimeConfig` 데이터클래스를 사용하여 설정과 환경 변수를 단일 컨텍스트로 관리합니다. 이를 통해 함수 간 파라미터 전달이 간소화되고, 새로운 설정 옵션을 쉽게 추가할 수 있습니다.
```python
from src.config import build_runtime_config
# config.json 로드 후
cfg = build_runtime_config(config_dict)
# cfg는 다음을 포함:
# - timeframe, indicator_timeframe, limit
# - telegram_bot_token, telegram_chat_id
# - upbit_access_key, upbit_secret_key
# - dry_run, max_threads, trading_mode 등
```
---
## 매도 로직 및 경계값 처리 정책
### 핵심 매도 조건
1. **손절 (조건1)**: 수익률 ≤ -5% → 전량 매도
2. **부분 익절 (조건3)**: 수익률 ≥ 10% 첫 도달 시 → 50% 매도 (1회만)
3. **트레일링 익절 (조건2/4/5)**: 최고점 대비 일정 하락률 초과 시 → 전량 매도
4. **수익률 보호 (조건4-2/5-2)**: 최고 수익률이 임계선(10%/30%)을 넘긴 후 다시 임계선 이하로 하락 시 → 전량 매도
### 경계값(Equality) 처리 규칙
**중요**: 수익률이 임계선(10%/30%)과 **정확히 일치(==)**하는 경우, 상황에 따라 다르게 처리됩니다:
#### 상승 중 경계선 도달 (부분익절)
- **조건**: `profit_rate >= 10%` (첫 도달)
- **동작**: 절반 매도 (50%)
- **논리**: 상승 추세에서 경계선 도달은 부분 익절 기회
#### 하락 중 경계선 도달 (수익률 보호)
- **조건**: 최고 수익률이 임계선을 초과한 뒤 `profit_rate <= threshold`로 하락
- **동작**: 전량 매도 (100%, stop_loss)
- **논리**: 최고점을 찍고 내려오는 중 경계선 도달은 하락 신호로 간주하여 수익 보호
**예시**:
```python
# 시나리오 1: 상승 중 10% 도달 (첫 진입)
# max_profit_rate: 10%, profit_rate: 10% → 부분익절 50% 매도
# 시나리오 2: 하락 중 10% 도달 (중간 구간 보호)
# max_profit_rate: 20%, profit_rate: 10% → 수익률 보호 100% 매도 (조건4-2)
# 시나리오 3: 하락 중 30% 도달 (고수익 구간 보호)
# max_profit_rate: 35%, profit_rate: 30% → 수익률 보호 100% 매도 (조건5-2)
```
**구현 상세** (`src/signals.py::evaluate_sell_conditions`):
- 부분익절: `if profit_rate >= profit_threshold_1:` (>= 사용)
- 중구간 보호: `if profit_rate <= profit_threshold_1:` (<= 사용)
- 고구간 보호: `if profit_rate <= profit_threshold_2:` (<= 사용)
---
## 실행 방법
1. **의존성 설치**:
```bash
python -m pip install -r requirements.txt
```
2. **환경 변수 설정** (Telegram 및 Upbit API 키):
PowerShell:
```powershell
$env:TELEGRAM_BOT_TOKEN = "YOUR_BOT_TOKEN"
$env:TELEGRAM_CHAT_ID = "YOUR_CHAT_ID"
$env:UPBIT_ACCESS_KEY = "YOUR_UPBIT_ACCESS_KEY"
$env:UPBIT_SECRET_KEY = "YOUR_UPBIT_SECRET_KEY"
```
또는 `.env` 파일 생성:
```
TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN
TELEGRAM_CHAT_ID=YOUR_CHAT_ID
UPBIT_ACCESS_KEY=YOUR_UPBIT_ACCESS_KEY
UPBIT_SECRET_KEY=YOUR_UPBIT_SECRET_KEY
```
3. **설정 파일 준비**:
```bash
copy config.example.json config.json
```
`config.json` 파일을 필요에 따라 수정합니다.
4. **프로그램 실행**:
```bash
python main.py
```
---
## 테스트 실행
1. **pytest 설치**:
```bash
python -m pip install pytest
```
2. **테스트 실행**:
```bash
pytest
```
---
이 문서는 프로젝트의 최신 구조와 실행 방법을 반영하도록 업데이트되었습니다.

54
config/config.json Normal file
View File

@@ -0,0 +1,54 @@
{
"symbols_file": "config/symbols.txt",
"symbol_delay": 1.0,
"candle_count": 200,
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"balance_warning_interval_hours": 24,
"min_amount_threshold": 1e-8,
"loop": false,
"dry_run": true,
"max_threads": 3,
"telegram_parse_mode": "HTML",
"macd_fast": 12,
"macd_slow": 26,
"macd_signal": 9,
"adx_length": 14,
"adx_threshold": 25,
"sma_short": 5,
"sma_long": 200,
"trading_mode": "auto_trade",
"auto_trade": {
"enabled": true,
"buy_enabled": true,
"buy_amount_krw": 15000,
"min_order_value_krw": 5000,
"allowed_symbols": [],
"require_env_confirm": false,
"buy_price_slippage_pct": 0.2,
"loss_threshold": -5.0,
"profit_threshold_1": 10.0,
"profit_threshold_2": 30.0,
"drawdown_1": 5.0,
"drawdown_2": 15.0,
"telegram_max_retries": 3,
"order_monitor_max_errors": 5
},
"confirm": {
"confirm_via_file": false,
"confirm_timeout": 300
},
"monitor": {
"enabled": true,
"timeout": 120,
"poll_interval": 3,
"max_retries": 1
},
"notify": {
"order_filled": true,
"order_partial": true,
"order_cancelled": true,
"order_error": true
}
}

105
config/symbols.txt Normal file
View File

@@ -0,0 +1,105 @@
# symbols.txt - 한 줄에 하나의 Upbit 심볼 입력
# 빈 줄과 #으로 시작하는 줄은 무시됨
# my watchlist
KRW-BTC
KRW-ETH
KRW-XRP
# KRW-BNB
KRW-SOL
KRW-TRX
KRW-DOGE
KRW-ADA
# KRW-HYPE
KRW-LINK
KRW-BCH
KRW-XLM
# KRW-LEO
# KRW-ZEC
# KRW-LTC
KRW-HBAR
KRW-AVAX
KRW-SUI
# KRW-XMR
KRW-SHIB
# KRW-TON
KRW-UNI
KRW-DOT
KRW-CRO
KRW-MNT
# KRW-TAO
KRW-WLFI
KRW-AAVE
KRW-NEAR
# KRW-ICP
# KRW-BGB
KRW-ETC
# KRW-OKB
# KRW-PEPE
# KRW-M
KRW-APT
KRW-ONDO
# KRW-ENA
# KRW-ASTER
# KRW-PI
# KRW-POL
# KRW-WLD
# KRW-VET
KRW-TRUMP
# KRW-KCS
# KRW-XAUT
# KRW-ARB
KRW-ALGO
# KRW-FIL
# KRW-SKY
# KRW-FLR
# KRW-IP
KRW-RENDER
# KRW-PAXG
# KRW-SEI
# KRW-PUMP
# KRW-KAS
# KRW-ATOM
# KRW-CAKE
# KRW-JUP
# KRW-BONK
# KRW-XDC
# KRW-TIA
# KRW-QNT
# KRW-AERO
# KRW-IMX
# KRW-DASH
# KRW-PEN
# KRW-GT
# KRW-VIR
# KRW-PYTH
# KRW-ENS
# KRW-AB
# KRW-SYRUP
# KRW-XPL
# KRW-FLOKI
# KRW-MORPHO
# KRW-INJ
# KRW-GRT
# KRW-LDO
# KRW-OP
# KRW-SAND
# KRW-TWT
# KRW-KAIA
# KRW-2Z
# KRW-ETHFI
# KRW-NEXO
# KRW-CRV
# KRW-DEXE
# KRW-CFX
# KRW-SPX
# KRW-IOTA
# KRW-FET
# KRW-XTZ
# KRW-STX
# KRW-PENDLE
# KRW-H
# KRW-WIF
# KRW-THETA
# KRW-JASMY
KRW-AWE
KRW-ARDR

1
data/holdings.json Normal file
View File

@@ -0,0 +1 @@
{}

101
docs/IMPROVEMENTS_REPORT.md Normal file
View File

@@ -0,0 +1,101 @@
# 권장 작업 완료 리포트 (2025-11-21)
## ✅ 완료된 작업
### 1. 코드 포맷팅 표준화
- **Black & ruff 설정 완료**
- `pyproject.toml`: Black/ruff/pytest 통합 설정 (line-length=120, Python 3.11)
- `.pre-commit-config.yaml`: Git hook 자동화 준비
- 17개 Python 파일 포맷팅 완료 (tabs → spaces)
### 2. 네트워크 복원력 강화
- **재시도 유틸리티 구현**
- `src/retry_utils.py`: Exponential backoff 데코레이터
- 최대 3회 재시도, 2초 → 4초 → 8초 (최대 10초)
- `fetch_holdings_from_upbit`에 적용: Upbit API 일시 장애 자동 복구
### 3. Graceful Shutdown 구현
- **안전한 종료 처리**
- SIGTERM/SIGINT 시그널 핸들러 등록
- 1초 간격으로 shutdown flag 확인 (빠른 반응성)
- 현재 작업 완료 후 안전 종료 보장
- Docker/systemd 환경 친화적
### 4. 테스트 검증
- **전체 테스트 통과**
- 22개 테스트 모두 PASSED (1.61초)
- Boundary conditions, Critical fixes, Sell conditions, Main functionality
- 회귀 없음 확인
### 5. 문서화
- `project_state.md` 업데이트: 상세 컨텍스트 및 설계 결정 기록
- `requirements.txt` 정리: 의존성 분류 및 코멘트 추가
## 📊 통계
```
포맷팅된 파일: 17개
신규 파일: 3개 (pyproject.toml, .pre-commit-config.yaml, src/retry_utils.py)
테스트 통과: 22/22 (100%)
코드 커버리지: Boundary(6), Critical(5), Sell(9), Main(2)
```
## 🎯 다음 단계 권장사항
### High Priority
1. **pre-commit 훅 설치**
```powershell
pre-commit install
```
2. **로그 rotation 설정**
- `RotatingFileHandler` 적용 (10MB, 5개 백업)
3. **Circuit breaker 패턴**
- 연속 API 실패 시 일정 시간 차단
### Medium Priority
1. 백테스트 엔진 설계
2. 성능 모니터링 메트릭
3. 경로 상수 테스트 커버리지
## 🔧 사용 방법
### 포맷팅 자동화
```powershell
# 전체 프로젝트 포맷팅
python -m black .
# Lint 체크
python -m ruff check .
# Lint 자동 수정
python -m ruff check --fix .
```
### Graceful Shutdown 테스트
```powershell
# 루프 모드 실행
python main.py
# 다른 터미널에서
Stop-Process -Name python -Signal SIGTERM # 또는 Ctrl+C
```
### 재시도 로직 모니터링
로그에서 `[RETRY]` 키워드로 재시도 이벤트 추적:
```
[RETRY] fetch_holdings_from_upbit 실패 (1/3): ConnectionError | 2.0초 후 재시도
[RETRY] fetch_holdings_from_upbit 최종 실패 (3/3): TimeoutError
```
## ✨ 핵심 개선 효과
1. **안정성 ↑**: API 장애 자동 복구, 안전한 종료
2. **유지보수성 ↑**: 일관된 코드 스타일, 명확한 구조
3. **운영 편의성 ↑**: Docker/systemd 친화적, 로그 가시성
4. **개발 생산성 ↑**: 포맷팅 자동화, CI/CD 준비 완료
---
**Status:** ✅ Production Ready
**Last Updated:** 2025-11-21
**Test Coverage:** 22/22 PASSED

View File

@@ -0,0 +1,35 @@
<!--
단계별 구현 체크리스트
implementation_plan.md
-->
# Implementation Plan
이 문서는 프로젝트의 개발 진행 상황을 추적합니다. AI는 이 문서를 참조하여 현재 단계(Context)를 파악하고 작업을 수행해야 합니다.
## Phase 1: 환경 설정 및 기반 구축 (Setup) [ ]
- [ ] 프로젝트 폴더 구조 생성 및 Git 초기화
- [ ] `copilot-instructions.md``.env` 환경 변수 템플릿 설정
- [ ] 언어별 패키지 매니저 설정 (requirements.txt, package.json, go.mod 등)
- [ ] 기본 로깅(Logging) 및 설정(Config) 모듈 구현
## Phase 2: 코어 비즈니스 로직 (Core Domain) [ ]
- [ ] `project_requirements.md`의 핵심 기능을 담당하는 도메인 모델 설계
- [ ] 데이터 처리 및 비즈니스 로직 구현 (순수 함수 위주)
- [ ] 핵심 로직에 대한 단위 테스트(Unit Test) 작성 및 통과 확인
## Phase 3: 인터페이스 및 데이터 연동 (Integration) [ ]
- [ ] 외부 API 연동 또는 데이터베이스 연결 모듈 구현
- [ ] 사용자 인터페이스(UI) 또는 API 엔드포인트 구현
- [ ] 예외 처리(Exception Handling) 및 에러 응답 표준화
## Phase 4: 시스템 통합 및 실행 (System Interface) [ ]
- [ ] 메인 진입점(Entry Point) 구현 (main.py, index.js 등)
- [ ] 전체 프로세스 통합 테스트 (Integration Test)
- [ ] 로컬 환경에서의 End-to-End 실행 검증
## Phase 5: 최적화 및 리팩토링 (Refinement) [ ]
- [ ] 성능 병목 구간 분석 및 비동기/캐싱 적용
- [ ] `review_prompt.md` 기반 자가 점검 및 코드 품질 개선
- [ ] 최종 문서화 (README.md 작성)
- [ ] 프로그램 사용법 작성 (user_guide.md)

View File

@@ -0,0 +1,45 @@
<!--
기획 및 로직 설계서
project_requirements.md
-->
# Product Requirements Document (PRD)
## 1. Project Overview
- **프로젝트명:** [프로젝트 이름 입력]
- **해결하려는 문제:** [이 프로젝트가 해결하고자 하는 핵심 문제 정의]
- **목표:** [프로젝트의 최종 성공 기준]
- **주요 타겟 유저:** [사용자 페르소나 정의]
## 2. Core Features (User Stories)
*(우선순위가 높은 순서대로 작성)*
1. **[핵심 기능 1]:** [사용자는 ~할 수 있다. 이를 통해 ~를 얻는다.]
2. **[핵심 기능 2]:** [상세 설명]
3. **[핵심 기능 3]:** [상세 설명]
4. **[부가 기능]:** [상세 설명]
## 3. Tech Stack & Architecture
- **Frontend:** [예: React, Tailwind CSS]
- **Backend:** [예: Python FastAPI, Node.js]
- **Database:** [예: PostgreSQL, Redis]
- **Infra:** [예: AWS Lambda, Docker]
## 4. Data Flow & Logic
- **Input:** [데이터 입력 소스]
- **Process:**
1. [단계 1: 데이터 수집/수신]
2. [단계 2: 핵심 비즈니스 로직 처리]
3. [단계 3: 데이터 저장 또는 가공]
- **Output:** [최종 결과물 형태]
## 5. File Structure Plan (Suggested)
*(AI가 제안하거나 개발자가 미리 지정)*
- `/src/core/` : 핵심 비즈니스 로직
- `/src/api/` : 외부 인터페이스 및 API 핸들러
- `/src/utils/` : 공통 유틸리티
- `/tests/` : 단위 및 통합 테스트
## 6. Non-Functional Requirements
- **성능:** [예: 응답 속도 200ms 이내, 동시 접속 1000명 처리]
- **보안:** [예: 모든 데이터 전송은 HTTPS, 민감 정보 암호화]
- **안정성:** [예: 외부 API 실패 시 재시도(Retry) 로직 구현]

166
docs/project_state.md Normal file
View File

@@ -0,0 +1,166 @@
# Current Session State
## 🎯 Current Phase
- **Phase:** Code Quality & Reliability Improvements (포맷팅, 재시도, Graceful Shutdown)
- **Focus:** 프로덕션 안정성 강화 및 코드베이스 표준화 완료
## ✅ Micro Tasks (ToDo)
- [x] IndentationError 버그 수정 (line 127)
- [x] Black/ruff 설정 파일 생성 (`pyproject.toml`, `.pre-commit-config.yaml`)
- [x] 전체 코드베이스 Black 포맷팅 (tabs→spaces, 17개 파일 재포맷)
- [x] Exponential backoff 재시도 유틸리티 구현 (`src/retry_utils.py`)
- [x] `fetch_holdings_from_upbit`에 재시도 데코레이터 적용
- [x] SIGTERM/SIGINT graceful shutdown 핸들러 추가
- [x] 루프 종료 로직 개선 (1초 간격으로 shutdown flag 확인)
- [x] 전체 테스트 스위트 실행 검증 (22 passed in 1.61s)
- [x] main.py 실행 테스트로 통합 검증
- [x] project_state.md 갱신
- [ ] pre-commit 훅 설치 및 CI 통합 (향후)
- [ ] 추가 통합 테스트 확장 (루프 모드 장시간 실행)
## 📝 Context Dump (Memo)
### 이번 세션 주요 개선사항 (2025-11-21):
#### 1. Bug Fix (IndentationError)
- **문제:** `process_symbols_and_holdings` 내부 Upbit 동기화 블록의 잘못된 들여쓰기
- **해결:** 들여쓰기 수준을 상위와 동일하게 정렬, 논리 변화 없음
- **검증:** `src/tests/test_main.py` 통과
#### 2. Code Formatting Standardization
- **도구:** Black (line-length=120), ruff (linter)
- **설정 파일:**
- `pyproject.toml`: Black/ruff/pytest 통합 설정
- `.pre-commit-config.yaml`: Git hook 자동화 준비
- **결과:** 17개 Python 파일 재포맷, 탭→스페이스 통일
- **영향:** diff 노이즈 해소, 향후 코드 리뷰 효율성 증가
#### 3. Network Resilience (재시도 로직)
- **신규 모듈:** `src/retry_utils.py`
- `@retry_with_backoff` 데코레이터 구현
- Exponential backoff (base=2.0, max_delay=10s)
- 기본 3회 재시도, 커스터마이징 가능
- **적용 대상:** `fetch_holdings_from_upbit` (holdings.py)
- **효과:** Upbit API 일시적 네트워크 오류 시 자동 재시도, 로그 기록
- **설계:** 범용 데코레이터로 향후 다른 API 호출에도 재사용 가능
#### 4. Graceful Shutdown
- **기능:**
- SIGTERM/SIGINT 시그널 핸들러 등록
- Global `_shutdown_requested` flag로 루프 제어
- 1초 간격 sleep으로 빠른 반응성 확보
- `finally` 블록으로 종료 로그 보장
- **효과:**
- Docker/systemd 환경에서 안전한 종료
- 긴급 중단 시에도 현재 작업 완료 후 종료
- KeyboardInterrupt 외 시그널 지원
#### 5. Advanced Log Management (추가 개선 - 2025-11-21)
- **다중 Rotation 전략:**
- **크기 기반:** 10MB 도달 시 자동 rotation, 7개 백업 유지
- **시간 기반:** 매일 자정 rotation, 30일 보관 (분석 편의성)
- **압축:** 오래된 로그 자동 gzip 압축 (70% 공간 절약)
- **로그 레벨 자동 최적화:**
- `dry_run=True`: INFO 레벨 (개발/테스트용 상세 로그)
- `dry_run=False`: WARNING 레벨 (운영 환경, 중요 이벤트만)
- 환경변수 `LOG_LEVEL`로 오버라이드 가능
- **용량 제한:**
- 크기 기반: 최대 80MB (10MB × 8개)
- 시간 기반: 최대 30일 (자동 삭제)
- 압축 후 실제 사용량: ~30-40MB 예상
- **파일 구조:**
```
logs/
├── AutoCoinTrader.log # 현재 로그 (크기 기반)
├── AutoCoinTrader.log.1.gz # 압축된 백업
├── AutoCoinTrader_daily.log # 현재 일일 로그
└── AutoCoinTrader_daily.log.2025-11-20 # 날짜별 백업
```
### 기존 경로/상수 리팩터 상태 (유지):
- 상수: `HOLDINGS_FILE`, `TRADES_FILE`, `PENDING_ORDERS_FILE` 중앙집중화 유지
- 파일 구조: `data/` 하위 관리 정상 작동
- 충돌 없음: 이번 개선사항은 기존 리팩터와 호환
### 테스트 결과 (검증 완료):
```
pytest src/tests/ -v
22 passed in 1.61s
```
- Boundary conditions: 6/6 passed
- Critical fixes: 5/5 passed
- Evaluate sell conditions: 9/9 passed
- Main functionality: 2/2 passed
### 설계 결정 및 트레이드오프:
#### 재시도 로직 설계:
- **장점:** API 장애 복원력, 운영 안정성 증가, 로그 가시성
- **트레이드오프:** 재시도 중 지연 발생 (최대 ~13초), 하지만 Upbit fetch는 비동기 백그라운드가 아니므로 허용 가능
- **대안 고려:** Circuit breaker 패턴 추가 (연속 실패 시 일정 시간 차단) → 추후 필요 시 구현
#### Graceful Shutdown 설계:
- **장점:** 안전한 종료, 데이터 무결성 보장, 운영 환경(Docker/systemd) 친화적
- **트레이드오ফ:** 1초 sleep 간격으로 약간의 CPU 체크 오버헤드, 하지만 무시 가능 수준
- **대안 고려:** Event 객체 사용 (threading.Event) → 더 파이썬스럽지만 현재 구현도 충분
#### Black 포맷팅 적용:
- **장점:** 코드 일관성, 리뷰 효율성, IDE 호환성
- **트레이드오프:** 기존 코드 전체 diff 발생 → 이번 세션에서 일괄 처리 완료
- **후속:** pre-commit hook 설치로 향후 자동화
### 향후 작업 후보 (우선순위):
1. **High Priority:**
- pre-commit 훅 설치 (`pre-commit install`) 및 CI/CD 통합
- ✅ **완료 (2025-11-21):** 로그 rotation 강화 (크기+시간+압축)
- Circuit breaker 패턴 추가 (연속 API 실패 대응)
2. **Medium Priority:**
- 백테스트 엔진 설계 착수 (캔들 재생성, 체결 시뮬레이션)
- 경로 상수 pytest 커버리지 증가
- 성능 모니터링 메트릭 수집 (처리 시간, API 응답 시간)
3. **Low Priority:**
- Prometheus/Grafana 통합 검토
- 알림 채널 다양화 (Slack, Discord 등)
- 다중 거래소 지원 확장 (Binance, Bithumb)
### 리스크/주의 (Updated):
- ✅ **해결됨:** 들여쓰기 통일 완료 (Black 적용)
- ✅ **해결됨:** Graceful shutdown 구현 완료
- ✅ **해결됨:** API 재시도 로직 추가 완료
- ⚠️ **남은 리스크:**
- ✅ **해결됨 (2025-11-21):** 로그 rotation 강화 (크기+시간 기반, 압축)
- Circuit breaker 없어 API 장기 장애 시 재시도 반복
- 다중 프로세스 환경 미지원 (holdings_lock은 thread-safe만 보장)
### 파일 변경 이력 (이번 세션):
```
신규 생성:
- pyproject.toml (Black/ruff/pytest 통합 설정)
- .pre-commit-config.yaml (Git hook 자동화)
- src/retry_utils.py (재시도 데코레이터)
주요 수정:
- main.py: signal handler, graceful shutdown 로직, 포맷팅
- src/holdings.py: retry 데코레이터 적용, 포맷팅
- src/common.py: 고급 로그 rotation (크기+시간+압축), 레벨 최적화
- src/*.py (전체 17개): Black 포맷팅 적용
테스트 통과:
- src/tests/*.py (22개 전체 PASSED)
```
### Next Phase (예정: 백테스트/평가 기능):
- 캔들 재생성 / 가상 체결 로직 추가
- 전략 파라미터 튜닝 지원 (threshold sweep)
- 결과 저장 포맷 통합 (trades.json 확장 또는 별도 `backtest_results.json`)
- 로그 rotation 및 성능 모니터링 메트릭 추가
### 현재 상태 요약:
**Production Ready:** 코드 품질, 안정성, 운영 환경 대응 모두 강화 완료
**테스트 커버리지:** 22개 테스트 전부 통과, 회귀 없음
**포맷팅:** Black/ruff 표준화 완료, 향후 자동화 준비됨
**신뢰성:** 네트워크 오류 재시도, 안전 종료 보장
📋 **다음 단계:** pre-commit 설치, 로그 rotation, 백테스트 모듈 착수

108
docs/review_prompt.md Normal file
View File

@@ -0,0 +1,108 @@
<!--
AI 코드 리뷰 지침
review_prompt.md
-->
<!-- 코드 리뷰 프롬프트(실무용, 핵심 위주) -->
<!--
# 코드 리뷰 프롬프트
다음 지침에 따라 코드를 검토하고 코드 리뷰 레포트(code_review_report.md)를 작성해주세요.
## [1. 분석 컨텍스트]
언어/환경: (예: Python 3.11, AWS Lambda)
코드 목적: (예: 결제 로직 처리)
핵심 요구: (예: 동시성 문제 해결, 성능 최적화)
## [2. 역할 및 원칙]
당신은 가장 까다로운 시니어 개발자입니다. 칭찬은 생략하고, 오직 **결함(Bug)**과 **위험 요소(Risk)**만 찾아내십시오.
- 원칙: 코드가 "문제없다"고 가정하지 마십시오. 숨겨진 논리적 오류, 예외 처리 누락, 보안 취약점을 집요하게 파고드십시오.
- 금지: 코드에 없는 내용을 추측하여 지적하지 마십시오(No Hallucination).
## [3. 중점 검토 항목]
1. 논리 오류: 엣지 케이스(Null, 0, 경계값)에서 로직이 깨지지 않는가?
2. 안정성: 예외가 발생했을 때 시스템이 안전하게 복구되거나 종료되는가? (Silent Failure 방지)
3. 보안: SQL 인젝션, XSS, 민감 정보(비번/키) 노출이 있는가?
4. 효율성: 불필요한 반복문(O(n²))이나 메모리 누수가 있는가?
## [4. 출력 형식]
발견된 문제가 없다면 "특이사항 없음"이라고 하십시오. 문제가 있다면 아래 양식으로 핵심만 적어주세요.
### 🚨 치명적 문제 (Critical)
- 위치: [라인 번호 또는 코드 스니펫]
- 이유: (왜 위험한지 기술적 설명)
- 해결책: (수정된 코드 블록)
### ⚠️ 개선 제안 (Warning)
- 위치: [라인 번호]
- 내용: (잠재적 위험 또는 가독성/성능 개선 제안)
-->
<!-- 코드 리뷰 프롬프트(심화버전) -->
# 코드 리뷰 프롬프트
다음 지침에 따라 코드를 검토하고 코드 리뷰 레포트(code_review_report.md)를 작성해주세요.
## [1. 분석 컨텍스트]
정확한 분석을 위해 아래 정보를 기반으로 코드를 검토하십시오.
- 언어/프레임워크: (예: Python 3.11, React 18, Spring Boot)
- 코드의 목적: (예: 사용자 인증, 데이터 파싱, 결제 트랜잭션)
- 주요 제약사항: (예: 높은 동시성, 메모리 최적화, 엄격한 보안 기준)
## [2. 역할 및 원칙]
당신은 '무관용 원칙'을 가진 수석 소프트웨어 아키텍트입니다.
- 목표: 칭찬보다는 결함(Bug), 보안 취약점, 성능 병목, 유지보수 저해 요소를 찾아내는 데 집중하십시오.
- 금지: 코드에 없는 내용을 추측하여 지적하지 마십시오(Zero Hallucination).
- 기준: "작동한다"에 만족하지 말고, "견고하고 안전한가"를 기준으로 판단하십시오.
## [3. 사전 단계: 의도 파악]
분석 전, 이 코드가 수행하는 핵심 로직을 3줄 이내로 요약하여, 당신이 코드를 올바르게 이해했는지 먼저 보여주십시오.
## [4. 심층 검토 체크리스트]
다음 항목을 기준으로 코드를 해부하십시오.
### 1. 논리 및 엣지 케이스 (Logic & Edge Cases)
- 가상 실행: 코드를 한 줄씩 추적하며 변수 상태 변화를 검증했는가?
- 경계값: Null, 빈 값, 음수, 최대값 등 극한의 입력에서 로직이 깨지지 않는가?
- 예외 처리: 에러를 단순히 삼키지 않고(Silent Failure), 적절히 처리하거나 전파하는가?
- 모듈성 및 API 설계: 함수/클래스의 공개 인터페이스(Public Interface)가 직관적이고 일관성이 있는가? 테스트 용이성(Testability)을 위해 의존성이 잘 분리되었는가?
### 2. 보안 및 안정성 (Security & Stability)
- 입력 검증: SQL 인젝션, XSS, 버퍼 오버플로우 취약점이 없는가?
- 정보 노출: 비밀번호, API 키, PII(개인정보)가 하드코딩되거나 로그에 남지 않는가?
- 자원 관리 및 메모리 안정성:
- 일반: 파일, DB 연결, 네트워크 소켓 등 모든 자원은 예외 발생 시에도 확실히 해제되는가?
- 언어별 핵심 원칙 (조건부 검증):
- (C++ 프로젝트인 경우): Raw pointer (`new`/`delete`)가 발견되지 않는가? 동적 자원은 스마트 포인터(RAII)로 관리되며, 함수가 예외 안전성을 제공하는가?
- (Python 프로젝트인 경우): 불필요한 참조 순환이나 메모리 누수 패턴이 없는가? 멀티스레딩 환경에서 동시성(Locking) 처리가 올바른가?
### 3. 동시성 및 성능 (Concurrency & Performance)
- 동기화: (해당 시) 경쟁 상태(Race Condition), 데드락, 스레드 안전성 문제가 없는가?
- 효율성: 불필요한 중첩 반복문(O(n²))이나 중복 연산이 없는가?
- 시스템 성능:
- I/O 병목: DB 쿼리나 네트워크 호출이 불필요하게 반복(N+1 쿼리)되거나 동기적으로 실행되어 시스템 전체를 막는 부분이 없는가?
- C++ (캐시 효율): 대용량 데이터 구조 설계 시 CPU 캐시 효율성(Cache Locality)이 고려되었는가?
## [5. 출력 형식: 결함 보고서]
발견된 문제가 없다면 "특이사항 없음"으로 명시하십시오. 문제가 있다면 아래 양식을 엄수해 주세요.
### 🚨 치명적 문제 (Critical Issues)
(서비스 중단, 데이터 손실/오염, 보안 사고 위험이 있는 경우)
[C-1] 문제 제목
├─ 위치: [파일경로:라인] 또는 [코드 스니펫 3~5줄]
├─ 원인: [기술적 원인 설명]
├─ 재현/조건: [문제가 발생하는 상황]
└─ 해결책: [수정된 코드 블록 (Auto-Fix)]
### ⚠️ 개선 제안 (Warnings & Improvements)
(성능 저하, 유지보수성 부족, 잠재적 버그)
[W-1] 문제 제목
├─ 위치: [파일경로:라인] 또는 [코드 스니펫]
├─ 분석: [문제점 설명]
└─ 권장 조치: [리팩토링 제안]
### ✅ 잘된 점 (Strengths)
(핵심적인 장점 1~2가지만 간결하게)

132
docs/user_guide.md Normal file
View File

@@ -0,0 +1,132 @@
# AutoCoinTrader 사용 가이드
## 개요
AutoCoinTrader는 Upbit 시세 데이터를 기반으로 MACD, SMA, ADX 지표를 활용해 매수/매도 신호를 분석하고(`dry_run` 모드), 설정에 따라 자동 주문까지 수행할 수 있는 트레이딩 보조/자동화 도구입니다. 모든 주요 상태 파일(`holdings.json`, `trades.json`, `pending_orders.json`)은 `data/` 디렉터리 하위에서 관리됩니다.
## 1. 사전 준비
### 1.1 필수 요구사항
1. Python 3.11 이상
2. 가상환경 권장 (venv, pyenv, Conda 등)
3. Upbit API 키 (자동매매 사용 시)
4. Telegram Bot Token & Chat ID (알림 사용 시)
### 1.2 설치
```powershell
python -m venv .venv; .\.venv\Scripts\activate
pip install -r requirements.txt
```
### 1.3 환경변수 설정 (`.env`)
`.env` 파일에 아래 항목을 필요 시 추가:
```bash
TELEGRAM_BOT_TOKEN=123456789:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TELEGRAM_CHAT_ID=123456789
UPBIT_ACCESS_KEY=YOUR_UPBIT_ACCESS
UPBIT_SECRET_KEY=YOUR_UPBIT_SECRET
# 선택 기능
AGGREGATE_ALERTS=false
TELEGRAM_TEST=0
AUTO_TRADE_ENABLED=0 # require_env_confirm 활성화 시 1로 설정해야 자동매매 진행
ORDER_MONITOR_TIMEOUT=120
ORDER_POLL_INTERVAL=3
ORDER_MAX_RETRIES=1
ORDER_MAX_CONSECUTIVE_ERRORS=5
```
`UPBIT_*` 키는 실거래(dry_run=False) + 자동매매(trading_mode=auto_trade/mixed)에서 필수.
`.env`는 반드시 `.gitignore`에 포함시키십시오.
## 2. 설정 파일 (`config/config.json`)
미존재 시 기본값이 내부 로직에서 자동 로드됩니다. 핵심 항목:
| 키 | 설명 | 기본값 |
|----|------|--------|
| candle_count | 각 심볼 지표 계산에 사용하는 캔들 개수 | 200 |
| buy_check_interval_minutes | 매수 조건 검사 주기 | 240 |
| stop_loss_check_interval_minutes | 손절 검사 주기 | 60 |
| profit_taking_check_interval_minutes | 익절 검사 주기 | 240 |
| loop | True면 주기적 루프 실행 | True |
| dry_run | True면 시뮬레이션 기록만 | True |
| max_threads | 병렬 스레드 수 | 3 |
| trading_mode | signal_only / auto_trade / mixed | signal_only |
| auto_trade.buy_enabled | 자동 매수 허용 여부 | False |
| auto_trade.buy_amount_krw | 1회 매수 금액 | 10000 |
| auto_trade.min_order_value_krw | 최소 주문 허용 KRW | 5000 |
| auto_trade.loss_threshold | 손절 기준(%) | -5.0 |
| auto_trade.profit_threshold_1 | 부분 익절 시작 수익률(%) | 10.0 |
| auto_trade.profit_threshold_2 | 전량 익절 고수익 기준(%) | 30.0 |
| auto_trade.drawdown_1 | 중간 구간 트레일링 하락률(%) | 5.0 |
| auto_trade.drawdown_2 | 고수익 구간 트레일링 하락률(%) | 15.0 |
## 3. 주요 데이터 파일 (data/)
| 파일 | 용도 |
|------|------|
| holdings.json | 현재 보유 코인 상태 (매수 평균가, 수량, max_price 등) |
| trades.json | 매수/매도 기록 (원자적 저장) |
| pending_orders.json | 사용자 확인 대기 중인 주문 토큰 목록 |
모든 경로는 코드 내부에서 `src.common`의 상수를 통해 관리되므로 재배치 시 해당 상수만 수정하면 됩니다.
## 4. 실행 방법
### 4.1 기본 실행 (드라이런)
```powershell
python main.py
```
루프 모드, 설정 주기에 따라 매수/손절/익절 검사 수행. `data/` 하위에 기록 생성.
### 4.2 벤치마크 실행
```powershell
python main.py --benchmark
```
순차/병렬 처리 시간 비교 후 로그 출력.
### 4.3 실거래 모드 전환
1. `config.json`에서 `dry_run``false`, `trading_mode``auto_trade` 또는 `mixed`로 설정.
2. `.env``UPBIT_ACCESS_KEY`, `UPBIT_SECRET_KEY` 추가.
3. (선택) `AUTO_TRADE_ENABLED=1` 설정 (require_env_confirm일 경우 필수).
4. 소액으로 먼저 검증 후 확장.
### 4.4 Telegram 알림 테스트
`.env``TELEGRAM_TEST=1` 설정 후 최초 실행 시 시작 메시지 전송.
## 5. 매수/매도 로직 개요
1. 매수 조건: MACD 교차, SMA 단기>장기, ADX 임계 초과 조합 (3가지 패턴)
2. 매도 조건: 손절(초기 하락), 부분 익절 후 수익 보호, 트레일링 익절 (구간별 다른 하락률 적용)
3. 부분 익절 1회 후 `partial_sell_done` 플래그로 중복 익절 방지.
## 6. 수동 확인 기반 주문 흐름
자동매매 모드에서 주문은 `pending_orders.json`에 기록 → 사용자가 `confirm_<토큰>` 파일 생성 → 타임아웃 내 확인되면 주문 실행.
## 7. 로그
`logs/AutoCoinTrader.log` 회전 로그(최대 10MB, 백업 7개). `LOG_DIR`, `LOG_LEVEL` 환경변수로 조정 가능.
## 8. Troubleshooting
| 증상 | 원인 | 해결 |
|------|------|------|
| 실거래 안 됨 | API 키 미설정 | `.env` 키 추가 및 dry_run False |
| 텔레그램 알림 없음 | 토큰/채팅ID 누락 | `.env` 값 확인 |
| 부분 체결 후 holdings 미반영 | 네트워크 지연 | 로그에서 monitor 결과 확인 후 수동 조정 |
| trades.json 손상 | 비정상 종료 | 자동 백업(`.corrupted.<ts>`) 후 재생성됨 |
## 9. 성능 & 안정성 팁
1. 스레드 수(`max_threads`)를 심볼 수 대비 과도하게 늘리지 말 것 (IO 포화 발생 가능).
2. 캔들 개수(`candle_count`)는 필요 이상 크게 하면 지표 계산 비용 증가.
3. 네트워크 오류 다발 시 `ORDER_MAX_CONSECUTIVE_ERRORS` 조정.
## 10. 백테스트 확장 (향후 계획)
`data/` 구조 유지 + 별도 `backtest/` 모듈에서 동일 지표 계산 후 결과 비교, `trades.json` 포맷 재사용. (구현 예정)
## 11. FAQ
**Q: dry_run과 auto_trade 차이?**
A: dry_run은 모든 거래를 기록만 하고 실제 주문을 보내지 않음. auto_trade는 조건 충족 시 주문 API 호출.
**Q: 부분 익절 후 재매도는 어떻게?**
A: `partial_sell_done` 플래그 True 후 트레일링 조건 또는 수익 보호 조건 충족 시 전량 매도.
**Q: trades.json 손상 시?**
A: 자동으로 `.corrupted.<timestamp>` 백업 후 새로 시작. 백업 파일 검토 가능.
**Q: 설정 변경 반영 시점?**
A: 프로세스 재시작 필요. 장기 실행 중에는 실시간 반영 안 함.
**Q: 최소 주문 금액 미만 상황?**
A: 매도/매수 모두 안전하게 건너뛰고 로그와 텔레그램 알림(선택)으로 통지.

70
docs/workflow.md Normal file
View File

@@ -0,0 +1,70 @@
<!-- 사용 방법 (워크플로우) -->
# Automated Development Workflow Protocol
이 문서는 AI가 프로젝트를 **자동으로 진행**하기 위한 행동 수칙을 정의합니다.
사용자가 **"워크플로우로 진행해줘"**라고 명령하면, AI는 아래 **[Execution Loop]**를 따릅니다.
## 🔄 Execution Loop (반복 실행 규칙)
AI는 다음 순서를 엄격히 준수해야 합니다.
1. **Status Check (상태 확인):**
- `implementation_plan.md`를 읽고, **체크되지 않은( `[ ]` ) 가장 첫 번째 Phase**를 식별합니다.
2. **Proposal & Approval (제안 및 승인 요청):**
- 사용자에게 현재 진행해야 할 Phase와 수행할 작업 내용을 요약하여 보고합니다.
- **"Phase X 작업을 시작하시겠습니까?"** 라고 묻고, **사용자의 승인(Yes/Go)을 대기**합니다. (즉시 코드를 작성하지 마십시오.)
3. **Execution (실행):**
- 사용자가 승인하면, 아래 **[Phase Detail Prompts]**에 정의된 해당 단계의 지침에 따라 코드를 작성합니다.
- 이때 반드시 `copilot-instructions.md`의 C++/Python 규칙과 `project_requirements.md`의 요구사항을 준수합니다.
4. **Update Plan (플랜 업데이트):**
- 작업이 완료되면 `implementation_plan.md`의 해당 항목을 체크(`[x]`) 표시하여 업데이트합니다.
5. **Self-Correction (자가 점검):**
- 작성된 코드가 `review_prompt.md`의 기준(성능, 예외 처리 등)을 충족하는지 확인하고, 부족한 점이 있다면 스스로 수정합니다.
---
## 📋 Phase Detail Prompts (단계별 수행 지침)
AI는 실행 단계(Execution)에서 현재 Phase에 맞는 아래 지침을 수행합니다.
### Phase 1: 환경 설정 (Setup)
- **목표:** 프로젝트 기반 마련 및 의존성 설정
- **지침:**
1. `copilot-instructions.md`의 Tech Stack을 확인하여 폴더 구조 생성.
2. Python(`requirements.txt`, `.env`) 및 C++(`CMakeLists.txt`) 설정 파일 작성.
3. `.gitignore` 및 기본 설정 파일 생성.
### Phase 2: 코어 로직 구현 (Core Domain)
- **목표:** 비즈니스 로직 및 데이터 모델 구현
- **지침:**
1. 코딩 전, **알고리즘 설계와 시간 복잡도**를 주석으로 먼저 작성.
2. `copilot-instructions.md`의 **Core Principles**를 준수하여 순수 함수/클래스 구현.
3. 반드시 **단위 테스트(Unit Test)** 코드를 함께 작성.
### Phase 3: 인터페이스 연동 (Integration)
- **목표:** 외부 API, DB, UI 연동
- **지침:**
1. 외부 시스템과의 통신 로직 구현.
2. **Error Handling:** 네트워크 실패, 타임아웃 등을 대비한 방어 코드(`try-except`, `RAII`) 작성.
3. `project_requirements.md`의 데이터 흐름(Data Flow) 준수 확인.
### Phase 4: 시스템 통합 (System Interface)
- **목표:** 메인 진입점 및 전체 프로세스 연결
- **지침:**
1. `main.py` 또는 `main.cpp` 진입점 구현.
2. 전체 모듈을 연결하고 통합 테스트(Integration Test) 시나리오 작성.
3. 실제 실행 가능한 상태인지 검증.
### Phase 5: 최적화 및 리팩토링 (Refinement)
- **목표:** 품질 향상 및 안정화
- **지침:**
1. `review_prompt.md`를 기준으로 전체 코드 리뷰 수행.
2. 성능 병목(O(N^2) 이상) 및 메모리 누수 점검.
3. 최종 문서(README.md) 작성.
---
## 🛑 Exception Handling
- 작업 중 에러가 발생하거나 정보가 부족하면 즉시 중단하고 사용자에게 구체적인 질문을 하십시오.
- 사용자가 "중단" 또는 "수정"을 요청하면 즉시 루프를 멈추고 지시를 따르십시오.

91
docs/workflow_manual.md Normal file
View File

@@ -0,0 +1,91 @@
<!-- 사용 방법 (워크플로우) -->
# Manual Usage & Prompt Guide
이 문서는 **자동 워크플로우(`workflow.md`)를 사용하지 않거나**, 특정 단계만 **수동으로 실행/재실행**해야 할 때 사용하는 프롬프트 모음집입니다. 상황에 맞는 프롬프트를 선택하여 AI에게 복사-붙여넣기 하세요.
---
## 1. 프로젝트 시작 및 초기화 (Initialization)
**상황:** 새로운 세션을 시작하거나, AI가 프로젝트 문맥을 잊어버렸을 때 사용합니다.
### 📂 Step 1: 문맥 주입 (Context Loading)
> "첨부된 `copilot-instructions.md`, `project_requirements.md`, `implementation_plan.md`를 모두 읽고, 이 프로젝트의 목표와 내가 구축하려는 시스템의 아키텍처를 요약해주세요. 그리고 `implementation_plan.md`의 단계들이 적절한지 검토해주세요."
### 🔍 Step 2: 계획 검증 (Plan Validation)
> "`implementation_plan.md`의 내용이 충분히 구체적인가? 만약 부족한 부분이 있다면 Python/C++ 프로젝트 표준에 맞춰서 수정해주세요. (수정이 없다면 이 단계는 생략)"
---
## 2. 단계별 실행 프롬프트 (Phase Execution)
**상황:** `implementation_plan.md`의 특정 단계를 실행할 때 사용합니다. (원하는 단계만 골라서 사용 가능)
### 🚀 Phase 1: 환경 설정 (Setup)
> "`implementation_plan.md`의 **[Phase 1]** 작업을 시작한다.
> `copilot-instructions.md`의 **Tech Stack**에 맞춰 폴더 구조를 잡고, 필요한 설정 파일(.env, requirements.txt, CMakeLists.txt 등)을 작성해줘.
> 작성 후에는 `implementation_plan.md`의 해당 항목을 체크(x)해주세요."
### 🧩 Phase 2: 코어 로직 구현 (Core Domain)
> "`implementation_plan.md`의 **[Phase 2]** 작업을 수행한다.
> `copilot-instructions.md`의 **Core Principles**를 준수하여 비즈니스 로직과 도메인 모델을 구현해주세요.
> **중요:** 코드를 작성하기 전에 **로직 설계와 시간 복잡도**를 먼저 설명하고, 반드시 **단위 테스트(Unit Test)** 코드를 함께 작성해주세요."
### 🔌 Phase 3: 인터페이스 연동 (Integration)
> "`implementation_plan.md`의 **[Phase 3]** 작업을 수행한다.
> 외부 API 연동, DB 연결, 또는 UI 컴포넌트를 구현해주세요.
> **Error Handling:** 예외 상황(네트워크 실패, DB 연결 끊김 등)에 대한 처리를 `try-except`(Python) 또는 `RAII/Exception Safety`(C++) 규칙에 맞춰 견고하게 작성해주세요."
### 🖥️ Phase 4: 시스템 통합 (System Interface)
> "`implementation_plan.md`의 **[Phase 4]** 작업을 수행한다.
> 전체 모듈을 하나로 묶는 메인 진입점(Main Entry Point)을 작성하고, 전체 프로세스가 유기적으로 동작하는지 검증하는 **통합 테스트(Integration Test)** 시나리오를 작성해주세요."
### ⚡ Phase 5: 최적화 및 리팩토링 (Refinement)
> "`implementation_plan.md`의 **[Phase 5]** 작업을 수행한다.
> 현재 코드에서 **성능 병목(O(N^2) 이상)**이 발생할 수 있는 구간이나 **메모리 누수** 가능성을 분석해주세요.
> 그 후, `review_prompt.md`의 기준에 따라 스스로 코드를 리뷰하고 개선안을 적용해주세요."
---
## 3. 자동 진행 및 복구 (Auto-Pilot & Recovery)
**상황:** 다음 할 일을 AI가 스스로 찾게 하거나, 꼬인 상황을 풀 때 사용합니다.
### 🤖 Auto-Pilot (알아서 다음 단계 진행)
> "`implementation_plan.md`를 확인해서 **아직 완료되지 않은(체크되지 않은) 가장 첫 번째 작업**을 수행해주세요.
> `copilot-instructions.md`의 규칙을 철저히 지켜서 코드를 작성하고, 작업이 끝나면 플랜 파일을 업데이트해주세요."
### 🚑 Troubleshooting (품질/방향성 교정)
**Q. AI가 엉뚱하거나 질 낮은 코드를 작성할 때**
> "방금 작성한 코드를 멈추고, `review_prompt.md`를 기준으로 다시 리뷰해주세요. 치명적인 결함이나 개선할 점을 찾아서 보고하고 코드를 수정해주세요."
**Q. 진행 상황(체크박스)이 실제와 다를 때**
*(사용자가 파일을 직접 수정한 뒤 명령)*
> "`implementation_plan.md` 파일을 다시 읽어봐. 내가 현재 진행 상황을 업데이트했으니, 체크되지 않은 항목부터 다시 작업을 이어가주세요."
copilot-instructions.md 지침을 따라서 작업해주세요.
project_requirements.md 파일에 작성된 템플릿에 맞춰 프로그램 요구사항을 작성한 후
implementation_plan.md 파일에 작성된 템플릿에 맞춰 작업계획을 작성해주세요.
프로그램 요구사항과 작업계획을 작성하기전에 필요한 데이터가 부족하면 물어보고 데이터 준비가 완료되면 작성해주세요.
프로그램에 요구되는 기능은 다음과 같습니다.
현재 개발된 코드에 추가적인 기능을 구현하려합니다.
제가 알고있기로는 trades.json 파일에 매수/매도한 종목을 기록하고있는것으로 알고있습니다.
매수/매도한 종목을 기록할때 "매수 평가" 또는 "매도 평가" 항목을 추가..의미가 없을 듯..
백테스트 프로젝트를 따로 만들고 백테스트 기능과 매매 평가 기능 추가 후 시뮬레이션 및 튜닝
더 필요한 내용이 있다면 물어봐주세요.
workflow.md 지침에 따라 작업을 진행해주세요.
review_prompt.md 지침에 따라 코드를 검토해주세요.
이 프로그램을 사용하는 방법을 user_guide.md 파일에 작성해줘.

91
git_init.bat.bat Normal file
View File

@@ -0,0 +1,91 @@
@echo off
chcp 65001 > nul
cls
echo ========================================================
echo Git 초기 설정 마법사 V2 (for Gitea)
echo ========================================================
echo.
echo [!] 이 파일은 프로젝트 폴더의 최상위에 위치해야 합니다.
echo [!] Gitea에서 저장소를 생성한 후, HTTPS 주소를 준비해주세요.
echo.
:: 1. 원격 저장소 URL 입력받기
set /p REMOTE_URL="[입력] Gitea 저장소 주소 (HTTPS)를 붙여넣으세요: "
if "%REMOTE_URL%"=="" (
echo [오류] 주소가 입력되지 않았습니다. 창을 닫고 다시 실행해주세요.
pause
exit
)
echo.
echo --------------------------------------------------------
echo [Step 0] Git 사용자 정보 확인...
:: 사용자 이름이 설정되어 있는지 확인합니다.
git config user.name >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo - 사용자 정보가 없습니다. 설정을 시작합니다.
echo.
set /p GIT_USER="[입력] 사용자 이름 (예: tae2564): "
set /p GIT_EMAIL="[입력] 이메일 주소 (예: tae2564@gmail.com): "
:: 입력받은 정보를 이 프로젝트에만 적용(local) 할지, PC 전체(global)에 할지 선택
:: 여기서는 편의상 Global로 설정합니다.
git config --global user.name "%GIT_USER%"
git config --global user.email "%GIT_EMAIL%"
echo - 사용자 정보 등록 완료!
) else (
echo - 기존 사용자 정보가 감지되었습니다. 건너뜁니다.
)
echo.
echo [Step 1] 저장소 초기화 중...
git init
echo.
echo [Step 2] .gitignore 파일 생성 중 (Python용)...
if not exist .gitignore (
(
echo __pycache__/
echo *.py[cod]
echo .venv/
echo venv/
echo .env
echo .vscode/
echo .idea/
echo *.log
) > .gitignore
echo - .gitignore 파일이 생성되었습니다.
) else (
echo - .gitignore 파일이 이미 존재하여 건너뜁니다.
)
echo.
echo [Step 3] 파일 담기 및 첫 커밋...
git add .
git commit -m "최초 프로젝트 업로드 (Script Auto Commit)"
echo.
echo [Step 4] 브랜치 이름 변경 (master - main)...
git branch -M main
echo.
echo [Step 5] 원격 저장소 연결...
git remote remove origin 2>nul
git remote add origin %REMOTE_URL%
echo.
echo [Step 6] 서버로 업로드 (Push)...
echo - 로그인 창이 뜨면 아이디와 비밀번호를 입력하세요.
git push -u origin main
echo.
echo ========================================================
if %ERRORLEVEL% == 0 (
echo [성공] 모든 설정이 완료되었습니다!
echo 이제부터는 git_upload.bat 파일을 사용해 수정사항을 올리세요.
) else (
echo [실패] 오류가 발생했습니다. 위 메시지를 확인해주세요.
)
echo ========================================================
pause

366
main.py Normal file
View File

@@ -0,0 +1,366 @@
import os
import time
import threading
import argparse
import signal
import sys
from dotenv import load_dotenv
import logging
# Modular imports
from src.common import logger, setup_logger, HOLDINGS_FILE
from src.config import load_config, read_symbols, get_symbols_file, build_runtime_config
from src.threading_utils import run_sequential, run_with_threads
from src.notifications import send_telegram, report_error, send_startup_test_message
from src.holdings import load_holdings, holdings_lock
from src.signals import check_stop_loss_conditions, check_profit_taking_conditions
# NOTE: Keep pandas_ta exposure for test monkeypatch compatibility
import pandas_ta as ta
load_dotenv()
# [중요] pyupbit/requests 교착 상태 방지용 초기화 코드
# dry_run=False로 설정 시 프로그램이 멈추는 현상을 해결합니다.
try:
import requests
requests.get("https://api.upbit.com/v1/market/all", timeout=1)
except Exception:
pass
def minutes_to_timeframe(minutes: int) -> str:
"""분 단위를 캔들봉 timeframe 문자열로 변환 (예: 60 -> '1h', 240 -> '4h')"""
if minutes < 60:
return f"{minutes}m"
elif minutes % 1440 == 0:
return f"{minutes // 1440}d"
elif minutes % 60 == 0:
return f"{minutes // 60}h"
else:
return f"{minutes}m"
def _check_buy_signals(cfg, symbols_to_check, config):
buy_signal_count = 0
buy_interval_minutes = config.get("buy_check_interval_minutes", 240)
buy_timeframe = minutes_to_timeframe(buy_interval_minutes)
logger.info("[SYSTEM] 매수 조건 확인 시작 (주기: %d분, 데이터: %s)", buy_interval_minutes, buy_timeframe)
if symbols_to_check:
from src.config import RuntimeConfig
cfg_with_buy_timeframe = RuntimeConfig(
timeframe=buy_timeframe,
indicator_timeframe=buy_timeframe,
candle_count=cfg.candle_count,
symbol_delay=cfg.symbol_delay,
interval=cfg.interval,
loop=cfg.loop,
dry_run=cfg.dry_run,
max_threads=cfg.max_threads,
telegram_parse_mode=cfg.telegram_parse_mode,
trading_mode=cfg.trading_mode,
telegram_bot_token=cfg.telegram_bot_token,
telegram_chat_id=cfg.telegram_chat_id,
upbit_access_key=cfg.upbit_access_key,
upbit_secret_key=cfg.upbit_secret_key,
aggregate_alerts=cfg.aggregate_alerts,
benchmark=cfg.benchmark,
telegram_test=getattr(cfg, "telegram_test", False),
config=cfg.config,
)
if cfg.max_threads > 1:
buy_signal_count = run_with_threads(
symbols_to_check, cfg=cfg_with_buy_timeframe, aggregate_enabled=cfg.aggregate_alerts
)
else:
buy_signal_count = run_sequential(
symbols_to_check, cfg=cfg_with_buy_timeframe, aggregate_enabled=cfg.aggregate_alerts
)
return buy_signal_count
def _check_sell_signals(cfg, config, holdings, current_time, last_stop_loss_check_time, last_profit_taking_check_time):
stop_loss_signal_count = 0
profit_taking_signal_count = 0
# 손절 분석
stop_loss_interval_min = config.get("stop_loss_check_interval_minutes", 60)
stop_loss_interval = stop_loss_interval_min * 60
if current_time - last_stop_loss_check_time >= stop_loss_interval:
logger.info("[SYSTEM] 손절 조건 확인 시작 (주기: %d분)", stop_loss_interval_min)
if holdings:
_, stop_loss_signal_count = check_stop_loss_conditions(holdings, cfg=cfg, config=config)
logger.info("보유 코인 손절 조건 확인 완료: %d개 신호", stop_loss_signal_count)
else:
logger.debug("보유 코인 없음 (손절 검사 건너뜀)")
last_stop_loss_check_time = current_time
else:
logger.debug(
"[DEBUG] 손절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)",
(stop_loss_interval - (current_time - last_stop_loss_check_time)) / 60,
)
# 익절 분석
profit_taking_interval_min = config.get("profit_taking_check_interval_minutes", 240)
profit_taking_interval = profit_taking_interval_min * 60
if current_time - last_profit_taking_check_time >= profit_taking_interval:
logger.info("[SYSTEM] 익절 조건 확인 시작 (주기: %d분)", profit_taking_interval_min)
if holdings:
_, profit_taking_signal_count = check_profit_taking_conditions(holdings, cfg=cfg, config=config)
logger.info("보유 코인 익절 조건 확인 완료: %d개 신호", profit_taking_signal_count)
else:
logger.debug("보유 코인 없음 (익절 검사 건너뜀)")
last_profit_taking_check_time = current_time
else:
logger.debug(
"[DEBUG] 익절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)",
(profit_taking_interval - (current_time - last_profit_taking_check_time)) / 60,
)
return stop_loss_signal_count, profit_taking_signal_count, last_stop_loss_check_time, last_profit_taking_check_time
def process_symbols_and_holdings(
cfg,
symbols: list,
config: dict,
last_buy_check_time: float,
last_stop_loss_check_time: float,
last_profit_taking_check_time: float,
last_balance_warning_time: float = 0,
) -> tuple:
"""Process all symbols once and check sell conditions for holdings."""
with holdings_lock:
holdings = load_holdings(HOLDINGS_FILE)
held_symbols = set(holdings.keys()) if holdings else set()
buy_candidate_symbols = [s for s in symbols if s not in held_symbols]
current_time = time.time()
buy_signal_count = 0
# 매수 분석
buy_interval_minutes = config.get("buy_check_interval_minutes", 240)
buy_interval = buy_interval_minutes * 60
if current_time - last_buy_check_time >= buy_interval:
buy_signal_count = _check_buy_signals(cfg, buy_candidate_symbols, config)
last_buy_check_time = current_time
else:
logger.debug(
"[DEBUG] 매수 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)",
(buy_interval - (current_time - last_buy_check_time)) / 60,
)
# Upbit 최신 보유 정보 동기화
if cfg.upbit_access_key and cfg.upbit_secret_key:
from src.holdings import save_holdings, fetch_holdings_from_upbit
updated_holdings = fetch_holdings_from_upbit(cfg)
if updated_holdings is not None:
holdings = updated_holdings
save_holdings(holdings, HOLDINGS_FILE)
else:
logger.error("Upbit에서 보유 정보를 가져오지 못했습니다. 이번 주기에서는 매도 분석을 건너뜁니다.")
# 매도 분석
stop_loss_signal_count, profit_taking_signal_count, last_stop_loss_check_time, last_profit_taking_check_time = (
_check_sell_signals(
cfg, config, holdings, current_time, last_stop_loss_check_time, last_profit_taking_check_time
)
)
logger.info(
"[INFO] [요약] 매수 신호: %d개, 손절 신호: %d개, 익절 신호: %d",
buy_signal_count,
stop_loss_signal_count,
profit_taking_signal_count,
)
return last_buy_check_time, last_stop_loss_check_time, last_profit_taking_check_time, last_balance_warning_time
def execute_benchmark(cfg, symbols):
"""Execute benchmark to compare single-thread and multi-thread performance."""
logger.info("[SYSTEM] 간단 벤치마크 시작: 심볼=%d", len(symbols))
# Run with single-thread
start = time.time()
run_sequential(symbols, cfg=cfg, aggregate_enabled=False)
elapsed_single = time.time() - start
logger.info("[INFO] 순차 처리 소요 시간: %.2f", elapsed_single)
# Run with configured threads (but in dry-run; user should enable dry_run for safe benchmark)
start = time.time()
run_with_threads(symbols, cfg=cfg, aggregate_enabled=False)
elapsed_parallel = time.time() - start
logger.info("[INFO] 병렬 처리(%d 스레드) 소요 시간: %.2f", cfg.max_threads, elapsed_parallel)
# Simple recommendation
if elapsed_parallel < elapsed_single:
reduction = (elapsed_single - elapsed_parallel) / elapsed_single * 100
logger.info("[INFO] 병렬로 %.1f%% 빨라졌습니다 (권장 스레드=%d).", reduction, cfg.max_threads)
else:
logger.info("[INFO] 병렬이 순차보다 빠르지 않습니다. 네트워크/IO 패턴을 점검하세요.")
# Global flag for graceful shutdown
_shutdown_requested = False
def _signal_handler(signum, frame):
"""Handle SIGTERM and SIGINT for graceful shutdown."""
global _shutdown_requested
sig_name = signal.Signals(signum).name if hasattr(signal, "Signals") else str(signum)
logger.info("[SYSTEM] 종료 시그널 수신: %s. 안전 종료를 시작합니다...", sig_name)
_shutdown_requested = True
def main():
# Parse command-line arguments
parser = argparse.ArgumentParser(description="MACD 알림 봇")
parser.add_argument("--benchmark", action="store_true", help="벤치마크 실행")
args = parser.parse_args()
# Load config
config = load_config()
if not config:
logger.error("[ERROR] 설정 로드 실패; 종료합니다")
return
# Build runtime config and derive core settings
cfg = build_runtime_config(config)
# dry_run 값에 따라 logger 핸들러 재설정
setup_logger(cfg.dry_run)
logger.info("[SYSTEM] " + "=" * 70)
logger.info("[SYSTEM] MACD 알림 봇 시작")
logger.info("[SYSTEM] " + "=" * 70)
# Load symbols
symbols = read_symbols(get_symbols_file(config))
if not symbols:
logger.error("[ERROR] 심볼 로드 실패; 종료합니다")
return
# Replace runtime_settings references with cfg attributes
if cfg.telegram_test:
send_startup_test_message(cfg.telegram_bot_token, cfg.telegram_chat_id, cfg.telegram_parse_mode, cfg.dry_run)
if not cfg.dry_run and (not cfg.telegram_bot_token or not cfg.telegram_chat_id):
logger.error("[ERROR] dry-run이 아닐 때 텔레그램 환경변수 필수: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID")
return
# 텔레그램 토큰 형식 검증
if cfg.telegram_bot_token:
from src.config import validate_telegram_token
if not validate_telegram_token(cfg.telegram_bot_token):
logger.warning("[WARNING] 텔레그램 봇 토큰 형식이 올바르지 않을 수 있습니다")
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
buy_check_minutes = config.get("buy_check_interval_minutes", 240)
stop_loss_check_minutes = config.get("stop_loss_check_interval_minutes", 60)
profit_taking_check_minutes = config.get("profit_taking_check_interval_minutes", 240)
logger.info(
"[SYSTEM] 설정: symbols=%d, symbol_delay=%.2f초, candle_count=%d, loop=%s, dry_run=%s, max_threads=%d, trading_mode=%s",
len(symbols),
cfg.symbol_delay,
cfg.candle_count,
cfg.loop,
cfg.dry_run,
cfg.max_threads,
cfg.trading_mode,
)
logger.info(
"[SYSTEM] 확인 주기: 매수=%d분, 손절=%d분, 익절=%d",
buy_check_minutes,
stop_loss_check_minutes,
profit_taking_check_minutes,
)
# Check if benchmark flag is set
if args.benchmark:
execute_benchmark(cfg, symbols)
return
# Main execution loop
last_buy_check_time = 0
last_stop_loss_check_time = 0
last_profit_taking_check_time = 0
last_balance_warning_time = 0
if not cfg.loop:
process_symbols_and_holdings(
cfg,
symbols,
config,
last_buy_check_time,
last_stop_loss_check_time,
last_profit_taking_check_time,
last_balance_warning_time,
)
else:
# 프로그램 루프 주기는 모든 확인 주기 중 가장 작은 값으로 자동 설정
loop_interval_minutes = min(buy_check_minutes, stop_loss_check_minutes, profit_taking_check_minutes)
interval_seconds = max(10, loop_interval_minutes * 60)
logger.info(
"[SYSTEM] 루프 모드 시작: %d분 간격 (매수: %d분, 손절: %d분, 익절: %d분 마다)",
loop_interval_minutes,
buy_check_minutes,
stop_loss_check_minutes,
profit_taking_check_minutes,
)
try:
while not _shutdown_requested:
start_time = time.time()
try:
(
last_buy_check_time,
last_stop_loss_check_time,
last_profit_taking_check_time,
last_balance_warning_time,
) = process_symbols_and_holdings(
cfg,
symbols,
config,
last_buy_check_time,
last_stop_loss_check_time,
last_profit_taking_check_time,
last_balance_warning_time,
)
except Exception as e:
logger.exception("[ERROR] 루프 내 작업 중 오류: %s", e)
report_error(
cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 루프 내 작업 실패: {e}", cfg.dry_run
)
if _shutdown_requested:
logger.info("[SYSTEM] 종료 요청으로 루프를 종료합니다")
break
# ✅ 작업 시간을 차감한 대기 시간 계산 (지연 누적 방지)
elapsed = time.time() - start_time
wait_seconds = max(10, interval_seconds - elapsed)
logger.info(
"[SYSTEM] 작업 소요: %.1f초 | 다음 실행까지 %.1f초 대기 (목표 주기: %d초)",
elapsed,
wait_seconds,
interval_seconds,
)
# Sleep in small intervals to check shutdown flag
sleep_interval = 1.0
slept = 0.0
while slept < wait_seconds and not _shutdown_requested:
time.sleep(min(sleep_interval, wait_seconds - slept))
slept += sleep_interval
except KeyboardInterrupt:
logger.info("[SYSTEM] 사용자가 루프를 중단함")
finally:
logger.info("[SYSTEM] 프로그램 종료 완료")
if __name__ == "__main__":
main()

48
pyproject.toml Normal file
View File

@@ -0,0 +1,48 @@
[tool.black]
line-length = 120
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
| __pycache__
)/
'''
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
]
ignore = [
"E501", # line too long (handled by black)
"B008", # do not perform function calls in argument defaults
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # imported but unused
[tool.pytest.ini_options]
testpaths = ["src/tests", "tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = src/tests

17
requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
# Core dependencies
pyupbit
pandas
pandas_ta
requests
python-dotenv
# Testing
pytest
# Code quality
black
ruff
pre-commit
# Utilities
chardet

4
src/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .common import logger
from .indicators import fetch_ohlcv, compute_macd_hist, ta
from .signals import evaluate_sell_conditions
from .notifications import send_telegram

111
src/common.py Normal file
View File

@@ -0,0 +1,111 @@
import os
import logging
from pathlib import Path
import logging.handlers
import gzip
import shutil
LOG_DIR = os.getenv("LOG_DIR", "logs")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, "AutoCoinTrader.log")
logger = logging.getLogger("macd_alarm")
_logger_configured = False
# 거래소 및 계산 상수
# 부동소수점 비교용 엡실론 (일반적 정밀도)
FLOAT_EPSILON = 1e-10
# 거래소별 최소 수량 (Upbit 기준)
MIN_TRADE_AMOUNT = 1e-8 # 0.00000001 (암호화폐 최소 단위)
# 최소 주문 금액 (KRW)
MIN_KRW_ORDER = 5000 # Upbit 최소 주문 금액
# 데이터 파일 경로 상수 (중앙 집중 관리)
DATA_DIR = Path("data")
DATA_DIR.mkdir(parents=True, exist_ok=True)
HOLDINGS_FILE = str(DATA_DIR / "holdings.json")
TRADES_FILE = str(DATA_DIR / "trades.json")
PENDING_ORDERS_FILE = str(DATA_DIR / "pending_orders.json")
class CompressedRotatingFileHandler(logging.handlers.RotatingFileHandler):
"""RotatingFileHandler with gzip compression for rotated logs."""
def rotation_filename(self, default_name):
"""Append .gz to rotated log files."""
return default_name + ".gz"
def rotate(self, source, dest):
"""Compress the rotated log file."""
if os.path.exists(source):
with open(source, "rb") as f_in:
with gzip.open(dest, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
os.remove(source)
def setup_logger(dry_run: bool):
"""
Configure logging with rotation and compression.
Args:
dry_run: If True, also output to console. If False, only to file.
Log Rotation Strategy:
- Size-based: 10MB per file, keep 7 backups (total ~80MB)
- Time-based: Daily rotation, keep 30 days
- Compression: Old logs are gzipped (saves ~70% space)
Log Levels (production recommendation):
- dry_run=True: INFO (development/testing)
- dry_run=False: WARNING (production - only important events)
"""
global logger, _logger_configured
if _logger_configured:
return
logger.handlers.clear()
# Use WARNING level for production, INFO for development
effective_level = getattr(logging, LOG_LEVEL, logging.INFO if dry_run else logging.WARNING)
logger.setLevel(effective_level)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s")
# Console handler (only in dry_run mode)
if dry_run:
ch = logging.StreamHandler()
ch.setLevel(effective_level)
ch.setFormatter(formatter)
logger.addHandler(ch)
# Size-based rotating file handler with compression
fh_size = CompressedRotatingFileHandler(
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=7, encoding="utf-8" # 10MB per file # Keep 7 backups
)
fh_size.setLevel(effective_level)
fh_size.setFormatter(formatter)
logger.addHandler(fh_size)
# Time-based rotating file handler (daily rotation, keep 30 days)
daily_log_file = os.path.join(LOG_DIR, "AutoCoinTrader_daily.log")
fh_time = logging.handlers.TimedRotatingFileHandler(
daily_log_file, when="midnight", interval=1, backupCount=30, encoding="utf-8"
)
fh_time.setLevel(effective_level)
fh_time.setFormatter(formatter)
fh_time.suffix = "%Y-%m-%d" # Add date suffix to rotated files
logger.addHandler(fh_time)
_logger_configured = True
logger.info(
"[SYSTEM] 로그 설정 완료: level=%s, size_rotation=%dMB×%d, daily_rotation=%d",
logging.getLevelName(effective_level),
10,
7,
30,
)

259
src/config.py Normal file
View File

@@ -0,0 +1,259 @@
import os, json
from dataclasses import dataclass
from typing import Optional
from .common import logger
def get_env_or_none(key: str) -> str | None:
"""환경변수를 로드하되, 없으면 None 반환 (빈 문자열도 None 처리)"""
value = os.getenv(key)
return value if value else None
def get_default_config() -> dict:
"""기본 설정 반환 (config.json 로드 실패 시 사용)"""
return {
"symbols_file": "config/symbols.txt",
"symbol_delay": 1.0,
"candle_count": 200,
"buy_check_interval_minutes": 240,
"stop_loss_check_interval_minutes": 60,
"profit_taking_check_interval_minutes": 240,
"balance_warning_interval_hours": 24,
"min_amount_threshold": 1e-8,
"loop": True,
"dry_run": True,
"max_threads": 3,
"telegram_parse_mode": "HTML",
"trading_mode": "signal_only",
"auto_trade": {
"enabled": False,
"buy_enabled": False,
"buy_amount_krw": 10000,
"min_order_value_krw": 5000,
"telegram_max_retries": 3,
"order_monitor_max_errors": 5,
},
}
def load_config() -> dict:
paths = [os.path.join("config", "config.json"), "config.json"]
example_paths = [os.path.join("config", "config.example.json"), "config.example.json"]
for p in paths:
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
cfg = json.load(f)
logger.info("설정 파일 로드: %s", p)
return cfg
except json.JSONDecodeError as e:
logger.error("설정 파일 JSON 파싱 실패: %s, 기본 설정 사용", e)
return get_default_config()
for p in example_paths:
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
cfg = json.load(f)
logger.warning("기본 설정 없음; 예제 사용: %s", p)
return cfg
except json.JSONDecodeError:
pass
logger.warning("설정 파일 없음: config/config.json, 기본 설정 사용")
return get_default_config()
def read_symbols(path: str) -> list:
syms = []
syms_set = set()
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
if s in syms_set:
logger.warning("[SYSTEM] 중복 심볼 무시: %s", s)
continue
syms_set.add(s)
syms.append(s)
logger.info("[SYSTEM] 심볼 %d개 로드: %s", len(syms), path)
except Exception as e:
logger.exception("[ERROR] 심볼 로드 실패: %s", e)
return syms
@dataclass(frozen=True)
class RuntimeConfig:
timeframe: str
indicator_timeframe: str
candle_count: int
symbol_delay: float
interval: int
loop: bool
dry_run: bool
max_threads: int
telegram_parse_mode: Optional[str]
trading_mode: str
telegram_bot_token: Optional[str]
telegram_chat_id: Optional[str]
upbit_access_key: Optional[str]
upbit_secret_key: Optional[str]
aggregate_alerts: bool = False
benchmark: bool = False
telegram_test: bool = False
config: dict = None # 원본 config 포함
def validate_telegram_token(token: str) -> bool:
"""텔레그램 봇 토큰 형식 검증 (형식: 숫자:영숫자_-)"""
import re
if not token:
return False
# 텔레그램 토큰 형식: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz-_1234567
# 첫 부분: 8-10자리 숫자, 두 번째 부분: 35자 이상
pattern = r"^\d{8,10}:[A-Za-z0-9_-]{35,}$"
return bool(re.match(pattern, token))
def build_runtime_config(cfg_dict: dict) -> RuntimeConfig:
# 설정값 검증
candle_count = int(cfg_dict.get("candle_count", 200))
if candle_count < 1:
logger.warning("[WARNING] candle_count는 1 이상이어야 합니다. 기본값 200 사용")
candle_count = 200
max_threads = int(cfg_dict.get("max_threads", 3))
if max_threads < 1:
logger.warning("[WARNING] max_threads는 1 이상이어야 합니다. 기본값 3 사용")
max_threads = 3
symbol_delay = float(cfg_dict.get("symbol_delay", 1.0))
if symbol_delay < 0:
logger.warning("[WARNING] symbol_delay는 0 이상이어야 합니다. 기본값 1.0 사용")
symbol_delay = 1.0
# timeframe은 동적으로 결정되므로 기본값만 설정 (실제로는 매수/매도 주기에 따라 변경됨)
timeframe = "1h" # 기본값
aggregate_alerts = bool(cfg_dict.get("aggregate_alerts", False)) or bool(
os.getenv("AGGREGATE_ALERTS", "False").lower() in ("1", "true", "yes")
)
benchmark = bool(cfg_dict.get("benchmark", False))
telegram_test = os.getenv("TELEGRAM_TEST", "0") == "1"
# auto_trade 서브 설정 논리 관계 검증 및 교정
at = cfg_dict.get("auto_trade", {}) or {}
loss_threshold = float(at.get("loss_threshold", -5.0))
p1 = float(at.get("profit_threshold_1", 10.0))
p2 = float(at.get("profit_threshold_2", 30.0))
d1 = float(at.get("drawdown_1", 5.0))
d2 = float(at.get("drawdown_2", 15.0))
# 손절 임계값 검증 (음수여야 함)
if loss_threshold >= 0:
logger.warning("[WARNING] loss_threshold(%.2f)는 음수여야 합니다 (예: -5.0). 기본값 -5.0 적용", loss_threshold)
loss_threshold = -5.0
elif loss_threshold < -50:
logger.warning(
"[WARNING] loss_threshold(%.2f)가 너무 작습니다 (최대 손실 50%% 초과). " "극단적인 손절선입니다.",
loss_threshold,
)
# 수익률 임계값 검증 (양수, 순서 관계, 합리적 범위)
if p1 <= 0 or p2 <= 0:
logger.warning("[WARNING] 수익률 임계값은 양수여야 합니다 -> 기본값 10/30 재설정")
p1, p2 = 10.0, 30.0
elif p1 >= p2:
logger.warning(
"[WARNING] profit_threshold_1(%.2f) < profit_threshold_2(%.2f) 조건 위반 " "-> 기본값 10/30 적용", p1, p2
)
p1, p2 = 10.0, 30.0
elif p1 < 5 or p2 > 200:
logger.warning(
"[WARNING] 수익률 임계값 범위 권장 벗어남 (p1=%.2f, p2=%.2f). " "권장 범위: 5%% <= p1 < p2 <= 200%%", p1, p2
)
# 드로우다운 임계값 검증 (양수, 순서 관계, 합리적 범위)
if d1 <= 0 or d2 <= 0:
logger.warning("[WARNING] drawdown 임계값은 양수여야 합니다 -> 기본값 5/15 재설정")
d1, d2 = 5.0, 15.0
elif d1 >= d2:
logger.warning("[WARNING] drawdown_1(%.2f) < drawdown_2(%.2f) 조건 위반 -> 기본값 5/15 적용", d1, d2)
d1, d2 = 5.0, 15.0
elif d1 > 20 or d2 > 50:
logger.warning(
"[WARNING] drawdown 값이 너무 큽니다 (d1=%.2f, d2=%.2f). "
"최대 손실 허용치를 확인하세요. 권장: d1 <= 20%%, d2 <= 50%%",
d1,
d2,
)
# 교정된 값 반영
at["profit_threshold_1"] = p1
at["profit_threshold_2"] = p2
at["drawdown_1"] = d1
at["drawdown_2"] = d2
cfg_dict["auto_trade"] = at
# 환경변수 로드
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
telegram_chat = os.getenv("TELEGRAM_CHAT_ID")
upbit_access = os.getenv("UPBIT_ACCESS_KEY")
upbit_secret = os.getenv("UPBIT_SECRET_KEY")
# dry_run 및 trading_mode 확인
dry_run = bool(cfg_dict.get("dry_run", False))
trading_mode = cfg_dict.get("trading_mode", "signal_only")
# 실거래 모드 시 필수 API 키 검증
if not dry_run and trading_mode in ("auto_trade", "mixed"):
if not (upbit_access and upbit_secret):
raise ValueError(
"[CRITICAL] 실거래 모드(dry_run=False)에서는 UPBIT_ACCESS_KEY 및 "
"UPBIT_SECRET_KEY 환경변수가 필수입니다. "
".env 파일을 확인하거나 환경변수를 설정하십시오."
)
logger.info(
"[SECURITY] Upbit API 키 로드 완료 (access_key 길이: %d, secret_key 길이: %d)",
len(upbit_access),
len(upbit_secret),
)
# 텔레그램 토큰 검증 (설정되어 있으면)
if telegram_token and not validate_telegram_token(telegram_token):
logger.warning(
"[WARNING] TELEGRAM_BOT_TOKEN 형식이 올바르지 않습니다 (형식: 숫자:영숫자_-). "
"알림 전송이 실패할 수 있습니다."
)
return RuntimeConfig(
timeframe=timeframe,
indicator_timeframe=timeframe,
candle_count=candle_count,
symbol_delay=symbol_delay,
interval=int(cfg_dict.get("interval", 60)),
loop=bool(cfg_dict.get("loop", False)),
dry_run=dry_run,
max_threads=max_threads,
telegram_parse_mode=cfg_dict.get("telegram_parse_mode"),
trading_mode=trading_mode,
telegram_bot_token=telegram_token,
telegram_chat_id=telegram_chat,
upbit_access_key=upbit_access,
upbit_secret_key=upbit_secret,
aggregate_alerts=aggregate_alerts,
benchmark=benchmark,
telegram_test=telegram_test,
config=cfg_dict,
)
def get_symbols_file(config: dict) -> str:
"""Determine the symbols file path with fallback logic."""
default_path = "config/symbols.txt" if os.path.exists("config/symbols.txt") else "symbols.txt"
return config.get("symbols_file", default_path)
# Define valid trading modes as constants
TRADING_MODES = ("signal_only", "auto_trade", "mixed")

333
src/holdings.py Normal file
View File

@@ -0,0 +1,333 @@
import os, json, pyupbit
from .common import logger, MIN_TRADE_AMOUNT, FLOAT_EPSILON, HOLDINGS_FILE
from .retry_utils import retry_with_backoff
import threading
# 부동소수점 비교용 임계값 (MIN_TRADE_AMOUNT와 동일한 용도)
EPSILON = FLOAT_EPSILON
# 파일 잠금을 위한 RLock 객체 (재진입 가능)
holdings_lock = threading.RLock()
def _load_holdings_unsafe(holdings_file: str) -> dict[str, dict]:
"""내부 사용 전용: Lock 없이 holdings 파일 로드"""
if os.path.exists(holdings_file):
if os.path.getsize(holdings_file) == 0:
logger.debug("[DEBUG] 보유 파일이 비어있습니다: %s", holdings_file)
return {}
with open(holdings_file, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def load_holdings(holdings_file: str = HOLDINGS_FILE) -> dict[str, dict]:
"""
holdings 파일을 로드합니다.
Returns:
심볼별 보유 정보 (symbol -> {amount, buy_price, ...})
"""
try:
with holdings_lock:
return _load_holdings_unsafe(holdings_file)
except json.JSONDecodeError as e:
logger.error("[ERROR] 보유 파일 JSON 디코드 실패: %s", e)
except Exception as e:
logger.exception("[ERROR] 보유 파일 로드 중 예외 발생: %s", e)
return {}
def _save_holdings_unsafe(holdings: dict[str, dict], holdings_file: str) -> None:
"""내부 사용 전용: Lock 없이 holdings 파일 저장 (원자적 쓰기)"""
os.makedirs(os.path.dirname(holdings_file) or ".", exist_ok=True)
temp_file = f"{holdings_file}.tmp"
try:
# 임시 파일에 먼저 쓰기
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(holdings, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno()) # 디스크 동기화 보장
# 원자적 교체 (rename은 원자적 연산)
os.replace(temp_file, holdings_file)
logger.debug("[DEBUG] 보유 저장 (원자적): %s", holdings_file)
except Exception as e:
logger.error("[ERROR] 보유 저장 중 오류: %s", e)
# 임시 파일 정리
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except Exception:
pass
raise
def save_holdings(holdings: dict[str, dict], holdings_file: str = HOLDINGS_FILE) -> None:
"""스레드 안전 + 원자적 파일 쓰기로 holdings 저장"""
try:
with holdings_lock:
_save_holdings_unsafe(holdings, holdings_file)
except Exception as e:
logger.error("[ERROR] 보유 저장 실패: %s", e)
raise # 호출자가 저장 실패를 인지하도록 예외 재발생
def get_upbit_balances(cfg: "RuntimeConfig") -> dict | None:
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("API 키 없음 - 빈 balances")
return {}
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
balances = upbit.get_balances()
# 타입 체크: balances가 리스트가 아닐 경우
if not isinstance(balances, list):
logger.error("Upbit balances 형식 오류: 예상(list), 실제(%s)", type(balances).__name__)
return None
result = {}
for item in balances:
currency = (item.get("currency") or "").upper()
try:
balance = float(item.get("balance", 0))
except Exception:
balance = 0.0
if balance <= MIN_TRADE_AMOUNT:
continue
result[currency] = balance
logger.debug("Upbit 보유 %d", len(result))
return result
except Exception as e:
logger.error("Upbit balances 실패: %s", e)
return None
def get_current_price(symbol: str) -> float:
try:
if symbol.upper().startswith("KRW-"):
market = symbol.upper()
else:
market = f"KRW-{symbol.replace('KRW-','').upper()}"
# 실시간 현재가(ticker)를 조회하도록 변경
price = pyupbit.get_current_price(market)
logger.debug("[DEBUG] 현재가 %s -> %.2f", market, price)
return float(price) if price else 0.0
except Exception as e:
logger.warning("[WARNING] 현재가 조회 실패 %s: %s", symbol, e)
return 0.0
def add_new_holding(
symbol: str, buy_price: float, amount: float, buy_timestamp: float | None = None, holdings_file: str = HOLDINGS_FILE
) -> bool:
"""
새로운 보유 자산을 추가하거나 기존 보유량을 추가합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
buy_price: 평균 매수가
amount: 매수한 수량
buy_timestamp: 매수 시각 (None이면 현재 시각 사용)
holdings_file: 보유 파일 경로
Returns:
성공 여부 (True/False)
"""
try:
import time
timestamp = buy_timestamp if buy_timestamp is not None else time.time()
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol in holdings:
# 기존 보유가 있으면 평균 매수가와 수량 업데이트
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
prev_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
total_amount = prev_amount + amount
if total_amount > 0:
# 가중 평균 매수가 계산
new_avg_price = ((prev_price * prev_amount) + (buy_price * amount)) / total_amount
holdings[symbol]["buy_price"] = new_avg_price
holdings[symbol]["amount"] = total_amount
# max_price 갱신: 현재 매수가와 기존 max_price 중 큰 값
prev_max = float(holdings[symbol].get("max_price", 0.0) or 0.0)
holdings[symbol]["max_price"] = max(new_avg_price, prev_max)
logger.info(
"[INFO] [%s] holdings 추가 매수: 평균가 %.2f -> %.2f, 수량 %.8f -> %.8f",
symbol,
prev_price,
new_avg_price,
prev_amount,
total_amount,
)
else:
# 신규 보유 추가
holdings[symbol] = {
"buy_price": buy_price,
"amount": amount,
"max_price": buy_price,
"buy_timestamp": timestamp,
"partial_sell_done": False,
}
logger.info("[INFO] [%s] holdings 신규 추가: 매수가=%.2f, 수량=%.8f", symbol, buy_price, amount)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 추가 실패: %s", symbol, e)
return False
def update_holding_amount(
symbol: str, amount_change: float, holdings_file: str = HOLDINGS_FILE, min_amount_threshold: float = 1e-8
) -> bool:
"""
보유 자산의 수량을 변경합니다 (매도 시 음수 값 전달).
수량이 0 이하가 되면 해당 심볼을 holdings에서 제거합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
amount_change: 변경할 수량 (매도 시 음수, 매수 시 양수)
holdings_file: 보유 파일 경로
min_amount_threshold: 0으로 간주할 최소 수량 임계값
Returns:
성공 여부 (True/False)
"""
try:
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol not in holdings:
logger.warning("[WARNING] [%s] holdings에 존재하지 않아 수량 업데이트 건너뜀", symbol)
return False
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
new_amount = max(0.0, prev_amount + amount_change)
if new_amount <= min_amount_threshold: # 거의 0이면 제거
holdings.pop(symbol, None)
logger.info(
"[INFO] [%s] holdings 업데이트: 전량 매도 완료, 보유 제거 (이전: %.8f, 변경: %.8f)",
symbol,
prev_amount,
amount_change,
)
else:
holdings[symbol]["amount"] = new_amount
logger.info(
"[INFO] [%s] holdings 업데이트: 수량 변경 %.8f -> %.8f (변경량: %.8f)",
symbol,
prev_amount,
new_amount,
amount_change,
)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 수량 업데이트 실패: %s", symbol, e)
return False
def set_holding_field(symbol: str, key: str, value, holdings_file: str = HOLDINGS_FILE) -> bool:
"""
보유 자산의 특정 필드 값을 설정합니다.
Args:
symbol: 거래 심볼 (예: KRW-BTC)
key: 설정할 필드의 키 (예: "partial_sell_done")
value: 설정할 값
holdings_file: 보유 파일 경로
Returns:
성공 여부 (True/False)
"""
try:
with holdings_lock:
holdings = _load_holdings_unsafe(holdings_file)
if symbol not in holdings:
logger.warning("[WARNING] [%s] holdings에 존재하지 않아 필드 설정 건너뜀", symbol)
return False
holdings[symbol][key] = value
logger.info("[INFO] [%s] holdings 업데이트: 필드 '%s''%s'(으)로 설정", symbol, key, value)
_save_holdings_unsafe(holdings, holdings_file)
return True
except Exception as e:
logger.exception("[ERROR] [%s] holdings 필드 설정 실패: %s", symbol, e)
return False
@retry_with_backoff(max_attempts=3, base_delay=2.0, max_delay=10.0, exceptions=(Exception,))
def fetch_holdings_from_upbit(cfg: "RuntimeConfig") -> dict | None:
try:
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
logger.debug("[DEBUG] API 키 없어 Upbit holdings 사용 안함")
return {}
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
balances = upbit.get_balances()
# 타입 체크: balances가 리스트가 아닐 경우
if not isinstance(balances, list):
logger.error(
"[ERROR] Upbit balances 형식 오류: 예상(list), 실제(%s), 값=%s", type(balances).__name__, balances
)
return None
holdings = {}
# 기존 holdings 파일에서 max_price 불러오기
existing_holdings = load_holdings(HOLDINGS_FILE)
for item in balances:
currency = (item.get("currency") or "").upper()
if currency == "KRW":
continue
try:
amount = float(item.get("balance", 0))
except Exception:
amount = 0.0
if amount <= EPSILON:
continue
buy_price = None
if item.get("avg_buy_price_krw"):
try:
buy_price = float(item.get("avg_buy_price_krw"))
except Exception:
buy_price = None
if buy_price is None and item.get("avg_buy_price"):
try:
buy_price = float(item.get("avg_buy_price"))
except Exception:
buy_price = None
market = f"KRW-{currency}"
# 기존 max_price 유지 (실시간 가격은 매도 검사 시점에 조회)
prev_max_price = None
if existing_holdings and market in existing_holdings:
prev_max_price = existing_holdings[market].get("max_price")
if prev_max_price is not None:
try:
prev_max_price = float(prev_max_price)
except Exception:
prev_max_price = None
# max_price는 기존 값 유지 또는 buy_price 사용
max_price = prev_max_price if prev_max_price is not None else (buy_price or 0)
holdings[market] = {
"buy_price": buy_price or 0.0,
"amount": amount,
"max_price": max_price,
"buy_timestamp": None,
}
logger.debug("[DEBUG] Upbit holdings %d", len(holdings))
return holdings
except Exception as e:
logger.error("[ERROR] fetch_holdings 실패: %s", e)
return None

167
src/indicators.py Normal file
View File

@@ -0,0 +1,167 @@
import os
import time
import random
import threading
import pandas as pd
import pandas_ta as ta
import pyupbit
from requests.exceptions import RequestException, Timeout, ConnectionError
from .common import logger
__all__ = ["fetch_ohlcv", "compute_macd_hist", "compute_sma", "ta", "DataFetchError", "clear_ohlcv_cache"]
class DataFetchError(Exception):
"""Custom exception for data fetching failures."""
pass
# OHLCV 데이터 캐시 (TTL 5분)
_ohlcv_cache = {}
_cache_lock = threading.RLock() # 캐시 동시 접근 보호 (재진입 가능)
CACHE_TTL = 300 # 5분
def clear_ohlcv_cache():
"""캐시 초기화 (테스트 또는 주문 체결 후 호출)"""
global _ohlcv_cache
with _cache_lock:
_ohlcv_cache.clear()
logger.debug("[CACHE] OHLCV 캐시 초기화")
def _clean_expired_cache():
"""만료된 캐시 항목 제거"""
global _ohlcv_cache
with _cache_lock:
now = time.time()
expired_keys = [k for k, (_, cached_time) in _ohlcv_cache.items() if now - cached_time >= CACHE_TTL]
for k in expired_keys:
del _ohlcv_cache[k]
if expired_keys:
logger.debug("[CACHE] 만료된 캐시 %d개 제거", len(expired_keys))
def fetch_ohlcv(
symbol: str, timeframe: str, limit: int = 200, log_buffer: list = None, use_cache: bool = True
) -> pd.DataFrame:
def _buf(level: str, msg: str):
if log_buffer is not None:
log_buffer.append(f"{level}: {msg}")
else:
getattr(logger, level.lower())(msg)
# 캐시 확인
cache_key = (symbol, timeframe, limit)
now = time.time()
if use_cache and cache_key in _ohlcv_cache:
cached_df, cached_time = _ohlcv_cache[cache_key]
if now - cached_time < CACHE_TTL:
_buf("debug", f"[CACHE HIT] OHLCV: {symbol} {timeframe} (age: {int(now - cached_time)}s)")
return cached_df.copy() # 복사본 반환으로 원본 보호
else:
# 만료된 캐시 제거
del _ohlcv_cache[cache_key]
_buf("debug", f"[CACHE EXPIRED] OHLCV: {symbol} {timeframe}")
# 주기적으로 만료 캐시 정리
if len(_ohlcv_cache) > 10:
_clean_expired_cache()
_buf("debug", f"[CACHE MISS] OHLCV 수집 시작: {symbol} {timeframe}")
tf_map = {
"1m": "minute1",
"3m": "minute3",
"5m": "minute5",
"15m": "minute15",
"30m": "minute30",
"1h": "minute60",
"4h": "minute240",
"1d": "day",
"1w": "week",
}
py_tf = tf_map.get(timeframe, timeframe)
max_attempts = int(os.getenv("MAX_FETCH_ATTEMPTS", "5"))
base_backoff = float(os.getenv("BASE_BACKOFF", "1.0"))
jitter_factor = float(os.getenv("BACKOFF_JITTER", "0.5"))
max_total_backoff = float(os.getenv("MAX_TOTAL_BACKOFF", "300"))
cumulative_sleep = 0.0
for attempt in range(1, max_attempts + 1):
try:
df = pyupbit.get_ohlcv(symbol, interval=py_tf, count=limit)
if df is None or df.empty:
_buf("warning", f"OHLCV 빈 결과: {symbol}")
raise RuntimeError("empty ohlcv")
# 'close' 컬럼 검증 및 안전한 처리
if "close" not in df.columns:
if len(df.columns) >= 4:
# pyupbit OHLCV 순서: open(0), high(1), low(2), close(3), volume(4)
df = df.rename(columns={df.columns[3]: "close"})
_buf("warning", f"'close' 컬럼 누락, 4번째 컬럼 사용: {symbol}")
else:
raise DataFetchError(f"OHLCV 데이터에 'close' 컬럼이 없고, 컬럼 수가 4개 미만: {symbol}")
# 캐시 저장 (Lock 보호)
if use_cache:
with _cache_lock:
_ohlcv_cache[cache_key] = (df.copy(), time.time())
_buf("debug", f"[CACHE SAVE] OHLCV: {symbol} {timeframe}")
_buf("debug", f"OHLCV 수집 완료: {symbol}")
return df
except Exception as e:
is_network_err = isinstance(e, (RequestException, Timeout, ConnectionError))
_buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}")
if not is_network_err:
_buf("error", f"네트워크 비관련 오류; 재시도하지 않음: {e}")
raise DataFetchError(f"네트워크 비관련 오류로 OHLCV 수집 실패: {e}")
if attempt == max_attempts:
_buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})")
raise DataFetchError(f"OHLCV 수집 최대 재시도({max_attempts}) 도달: {symbol}")
sleep_time = base_backoff * (2 ** (attempt - 1))
sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time)
if cumulative_sleep + sleep_time > max_total_backoff:
logger.warning("누적 재시도 대기시간 초과 (%s)", symbol)
raise DataFetchError(f"OHLCV 수집 누적 대기시간 초과: {symbol}")
cumulative_sleep += sleep_time
_buf("debug", f"{sleep_time:.2f}초 후 재시도")
time.sleep(sleep_time)
raise DataFetchError(f"OHLCV 수집 로직의 마지막에 도달했습니다. 이는 발생해서는 안 됩니다: {symbol}")
def compute_macd_hist(close_series: pd.Series, log_buffer: list = None) -> pd.Series:
def _buf(level: str, msg: str):
if log_buffer is not None:
log_buffer.append(f"{level}: {msg}")
else:
getattr(logger, level.lower())(msg)
try:
macd_df = ta.macd(close_series, fast=12, slow=26, signal=9)
hist_cols = [c for c in macd_df.columns if "MACDh" in c]
if not hist_cols:
_buf("error", "MACD histogram column not found")
raise ValueError("MACD histogram 컬럼을 찾을 수 없습니다")
return macd_df[hist_cols[0]]
except Exception as e:
_buf("error", f"MACD 계산 실패: {e}")
raise # 예외를 호출자에게 전파하여 명시적 처리 강제
def compute_sma(close_series: pd.Series, window: int, log_buffer: list = None) -> pd.Series:
"""단순 이동평균선(SMA) 계산"""
def _buf(level: str, msg: str):
if log_buffer is not None:
log_buffer.append(f"{level}: {msg}")
else:
getattr(logger, level.lower())(msg)
try:
return close_series.rolling(window=window).mean()
except Exception as e:
_buf("error", f"SMA{window} 계산 실패: {e}")
raise # 예외를 호출자에게 전파하여 명시적 처리 강제

108
src/notifications.py Normal file
View File

@@ -0,0 +1,108 @@
import os
import threading
import time
import requests
from .common import logger
__all__ = ["send_telegram", "send_telegram_with_retry", "report_error", "send_startup_test_message"]
def send_telegram_with_retry(
token: str,
chat_id: str,
text: str,
add_thread_prefix: bool = True,
parse_mode: str = None,
max_retries: int | None = None,
) -> bool:
"""
재시도 로직이 포함된 텔레그램 메시지 전송
Args:
token: 텔레그램 봇 토큰
chat_id: 채팅 ID
text: 메시지 내용
add_thread_prefix: 스레드 prefix 추가 여부
parse_mode: HTML/Markdown 파싱 모드
max_retries: 최대 재시도 횟수 (None이면 기본값 3)
Returns:
성공 여부 (True/False)
"""
if max_retries is None:
max_retries = 3
for attempt in range(max_retries):
try:
# 이제 send_telegram은 실패 시 예외를 발생시킴
send_telegram(token, chat_id, text, add_thread_prefix, parse_mode)
return True
except Exception as e:
if attempt < max_retries - 1:
wait_time = 2**attempt # Exponential backoff: 1s, 2s, 4s
logger.warning(
"텔레그램 전송 실패 (시도 %d/%d), %d초 후 재시도: %s", attempt + 1, max_retries, wait_time, e
)
time.sleep(wait_time)
else:
logger.error("텔레그램 전송 최종 실패 (%d회 시도): %s", max_retries, e)
return False
return False
def send_telegram(token: str, chat_id: str, text: str, add_thread_prefix: bool = True, parse_mode: str = None):
"""
텔레그램 메시지를 한 번 전송합니다. 실패 시 예외를 발생시킵니다.
"""
if add_thread_prefix:
thread_name = threading.current_thread().name
# 기본 Thread-N 이름이면 prefix 생략 (의미 없는 정보)
if not thread_name.startswith("Thread-"):
payload_text = f"[{thread_name}] {text}"
else:
payload_text = text
else:
payload_text = text
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_id, "text": payload_text}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
resp = requests.post(url, json=payload, timeout=10)
resp.raise_for_status() # 2xx 상태 코드가 아니면 HTTPError 발생
logger.debug("텔레그램 메시지 전송 성공: %s", text[:80])
return True
except requests.exceptions.RequestException as e:
logger.warning("텔레그램 API 요청 실패: %s", e)
raise # 예외를 다시 발생시켜 호출자가 처리하도록 함
def report_error(bot_token: str, chat_id: str, message: str, dry_run: bool):
"""
Report an error via Telegram.
"""
if not dry_run and bot_token and chat_id:
# 재시도 로직이 포함된 함수 사용
send_telegram_with_retry(bot_token, chat_id, message, add_thread_prefix=True)
def send_startup_test_message(bot_token: str, chat_id: str, parse_mode: str, dry_run: bool):
"""
Send a startup test message to verify Telegram settings.
"""
if dry_run:
logger.info("[dry-run] Telegram 테스트 메시지 전송 생략")
return
if bot_token and chat_id:
test_msg = "[테스트] Telegram 설정 확인용 메시지입니다. 봇/채팅 설정이 올바르면 이 메시지가 도착합니다."
logger.info("텔레그램 테스트 메시지 전송 시도")
# 재시도 로직이 포함된 함수 사용
if send_telegram_with_retry(bot_token, chat_id, test_msg, add_thread_prefix=False, parse_mode=parse_mode):
logger.info("텔레그램 테스트 메시지 전송 성공")
else:
logger.warning("텔레그램 테스트 메시지 전송 실패")
else:
logger.warning("TELEGRAM_TEST=1 이지만 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID가 설정되어 있지 않습니다")

759
src/order.py Normal file
View File

@@ -0,0 +1,759 @@
import os
import time
import json
import secrets
import threading
import pyupbit
from .common import logger, MIN_KRW_ORDER, HOLDINGS_FILE, TRADES_FILE, PENDING_ORDERS_FILE
from .notifications import send_telegram
def adjust_price_to_tick_size(price: float) -> float:
"""
Upbit 호가 단위에 맞춰 가격을 조정합니다.
pyupbit.get_tick_size를 사용하여 실시간 호가 단위를 가져옵니다.
"""
try:
tick_size = pyupbit.get_tick_size(price)
adjusted_price = round(price / tick_size) * tick_size
return adjusted_price
except Exception as e:
logger.warning("호가 단위 조정 실패: %s. 원본 가격 사용.", e)
return price
def _make_confirm_token(length: int = 16) -> str:
return secrets.token_hex(length)
_pending_order_lock = threading.Lock()
def _write_pending_order(token: str, order: dict, pending_file: str = PENDING_ORDERS_FILE):
with _pending_order_lock:
try:
pending = []
if os.path.exists(pending_file):
with open(pending_file, "r", encoding="utf-8") as f:
try:
pending = json.load(f)
except Exception:
pending = []
pending.append({"token": token, "order": order, "timestamp": time.time()})
with open(pending_file, "w", encoding="utf-8") as f:
json.dump(pending, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.exception("pending_orders 기록 실패: %s", e)
_confirmation_lock = threading.Lock()
def _check_confirmation(token: str, timeout: int = 300) -> bool:
start = time.time()
confirm_file = f"confirm_{token}"
while time.time() - start < timeout:
# 1. Atomic file check
try:
os.rename(confirm_file, f"{confirm_file}.processed")
logger.info("토큰 파일 확인 성공: %s", confirm_file)
return True
except FileNotFoundError:
pass
except Exception as e:
logger.warning("토큰 파일 처리 오류: %s", e)
time.sleep(2)
return False
def notify_order_result(
symbol: str, monitor_result: dict, config: dict, telegram_token: str, telegram_chat_id: str
) -> bool:
if not (telegram_token and telegram_chat_id):
return False
notify_cfg = config.get("notify", {}) if config else {}
final_status = monitor_result.get("final_status", "unknown")
filled = monitor_result.get("filled_volume", 0.0)
remaining = monitor_result.get("remaining_volume", 0.0)
attempts = monitor_result.get("attempts", 0)
should_notify = False
msg = ""
if final_status == "filled" and notify_cfg.get("order_filled", True):
should_notify = True
msg = f"[주문완료] {symbol}\n체결량: {filled:.8f}\n상태: 완전체결"
elif final_status in ("partial", "timeout", "cancelled"):
if final_status == "partial" and notify_cfg.get("order_partial", True):
should_notify = True
msg = f"[부분체결] {symbol}\n체결량: {filled:.8f}\n잔여량: {remaining:.8f}"
elif final_status == "timeout" and notify_cfg.get("order_partial", True):
should_notify = True
msg = f"[타임아웃] {symbol}\n체결량: {filled:.8f}\n잔여량: {remaining:.8f}\n재시도: {attempts}"
elif final_status == "cancelled" and notify_cfg.get("order_cancelled", True):
should_notify = True
msg = f"[주문취소] {symbol}\n취소 사유: 사용자 미확인 또는 오류"
elif final_status in ("error", "unknown") and notify_cfg.get("order_error", True):
should_notify = True
msg = f"[주문오류] {symbol}\n상태: {final_status}\n마지막 확인: {monitor_result.get('last_checked', 'N/A')}"
if should_notify and msg:
try:
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False)
return True
except Exception as e:
logger.exception("주문 결과 알림 전송 실패: %s", e)
return False
return False
def _calculate_and_add_profit_rate(trade_record: dict, symbol: str, monitor: dict):
"""
매도 거래 기록에 수익률 정보를 계산하여 추가합니다.
"""
try:
from .holdings import load_holdings, get_current_price
holdings = load_holdings(HOLDINGS_FILE)
if symbol not in holdings:
return
buy_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
# 실제 평균 매도가 계산
sell_price = 0.0
if monitor and monitor.get("last_order"):
last_order = monitor["last_order"]
trades = last_order.get("trades", [])
if trades:
total_krw = sum(float(t.get("price", 0)) * float(t.get("volume", 0)) for t in trades)
total_volume = sum(float(t.get("volume", 0)) for t in trades)
if total_volume > 0:
sell_price = total_krw / total_volume
# 매도가가 없으면 현재가 사용
if sell_price <= 0:
sell_price = get_current_price(symbol)
# 수익률 계산 및 기록 추가
if buy_price > 0 and sell_price > 0:
profit_rate = ((sell_price - buy_price) / buy_price) * 100
trade_record["buy_price"] = buy_price
trade_record["sell_price"] = sell_price
trade_record["profit_rate"] = round(profit_rate, 2)
logger.info(
"[%s] 매도 수익률: %.2f%% (매수가: %.2f, 매도가: %.2f)", symbol, profit_rate, buy_price, sell_price
)
else:
logger.warning("[%s] 수익률 계산 불가: buy_price=%.2f, sell_price=%.2f", symbol, buy_price, sell_price)
trade_record["profit_rate"] = None
except Exception as e:
logger.warning("매도 수익률 계산 중 오류 (기록은 계속 진행): %s", e)
trade_record["profit_rate"] = None
def place_buy_order_upbit(market: str, amount_krw: float, cfg: "RuntimeConfig") -> dict:
"""
Upbit API를 이용한 매수 주문 (시장가 또는 지정가)
Args:
market: 거래 시장 (예: KRW-BTC)
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
Returns:
주문 결과 딕셔너리
"""
from .holdings import get_current_price
# config에서 buy_price_slippage_pct 읽기
auto_trade_cfg = cfg.config.get("auto_trade", {})
slippage_pct = float(auto_trade_cfg.get("buy_price_slippage_pct", 0.0))
if cfg.dry_run:
price = get_current_price(market)
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
logger.info(
"[place_buy_order_upbit][dry-run] %s 매수 금액=%.2f KRW, 지정가=%.2f", market, amount_krw, limit_price
)
return {
"market": market,
"side": "buy",
"amount_krw": amount_krw,
"price": limit_price,
"status": "simulated",
"timestamp": time.time(),
}
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
msg = "Upbit API 키 없음: 매수 주문을 실행할 수 없습니다"
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
try:
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
price = get_current_price(market)
# 현재가 검증
if price <= 0:
msg = f"[매수 실패] {market}: 현재가 조회 실패 (price={price})"
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
limit_price = price * (1 + slippage_pct / 100) if price > 0 and slippage_pct > 0 else price
resp = None
if slippage_pct > 0 and limit_price > 0:
# 지정가 매수: 호가 단위에 맞춰 가격 조정
adjusted_limit_price = adjust_price_to_tick_size(limit_price)
volume = amount_krw / adjusted_limit_price
# 🔒 안전성 검증: 파라미터 최종 확인
if adjusted_limit_price <= 0 or volume <= 0:
msg = f"[매수 실패] {market}: 비정상 파라미터 (price={adjusted_limit_price}, volume={volume})"
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
# pyupbit API: buy_limit_order(ticker, price, volume)
# - ticker: 마켓 심볼 (예: "KRW-BTC")
# - price: 지정가 (KRW, 예: 50000000)
# - volume: 매수 수량 (코인 개수, 예: 0.001)
logger.info(
"[매수 주문 전 검증] %s | 지정가=%.2f KRW | 수량=%.8f개 | 예상 총액=%.2f KRW",
market,
adjusted_limit_price,
volume,
adjusted_limit_price * volume,
)
resp = upbit.buy_limit_order(market, adjusted_limit_price, volume)
logger.info(
"✅ Upbit 지정가 매수 주문 완료: %s | 지정가=%.2f (조정전: %.2f) | 수량=%.8f | 목표금액=%.2f KRW",
market,
adjusted_limit_price,
limit_price,
volume,
amount_krw,
)
else:
# 시장가 매수: amount_krw 금액만큼 시장가로 매수
# pyupbit API: buy_market_order(ticker, price)
# - ticker: 마켓 심볼
# - price: 매수할 KRW 금액 (예: 15000)
logger.info("[매수 주문 전 검증] %s | 시장가 매수 | 금액=%.2f KRW", market, amount_krw)
resp = upbit.buy_market_order(market, amount_krw)
logger.info("✅ Upbit 시장가 매수 주문 완료: %s | 금액=%.2f KRW", market, amount_krw)
if isinstance(resp, dict):
order_uuid = resp.get("uuid")
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
else:
logger.info("Upbit 주문 응답: %s", resp)
result = {
"market": market,
"side": "buy",
"amount_krw": amount_krw,
"price": limit_price if slippage_pct > 0 else None,
"status": "placed",
"response": resp,
"timestamp": time.time(),
}
try:
order_uuid = None
if isinstance(resp, dict):
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
if order_uuid:
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
result["monitor"] = monitor_res
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
except Exception:
logger.debug("매수 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
logger.exception("Upbit 매수 주문 실패: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
def place_sell_order_upbit(market: str, amount: float, cfg: "RuntimeConfig") -> dict:
"""
Upbit API를 이용한 시장가 매도 주문
Args:
market: 거래 시장 (예: KRW-BTC)
amount: 매도할 코인 수량
cfg: RuntimeConfig 객체
Returns:
주문 결과 딕셔너리
"""
if cfg.dry_run:
logger.info("[place_sell_order_upbit][dry-run] %s 매도 수량=%.8f", market, amount)
return {"market": market, "side": "sell", "amount": amount, "status": "simulated", "timestamp": time.time()}
if not (cfg.upbit_access_key and cfg.upbit_secret_key):
msg = "Upbit API 키 없음: 매도 주문을 실행할 수 없습니다"
logger.error(msg)
return {"error": msg, "status": "failed", "timestamp": time.time()}
try:
upbit = pyupbit.Upbit(cfg.upbit_access_key, cfg.upbit_secret_key)
# 최소 주문 금액(설정값, 기본 5,000 KRW) 이하일 경우 매도 건너뜀
try:
from .holdings import get_current_price
current_price = float(get_current_price(market))
except Exception:
current_price = 0.0
# 현재가 조회 실패 시 안전하게 매도 차단
if current_price <= 0:
msg = f"[매도 실패] {market}\n사유: 현재가 조회 실패\n매도 수량: {amount:.8f}"
logger.error(msg)
return {
"market": market,
"side": "sell",
"amount": amount,
"status": "failed",
"error": "price_unavailable",
"timestamp": time.time(),
}
estimated_value = amount * current_price
# 최소 주문 금액 안전 파싱 (누락/형식 오류 대비)
raw_min = cfg.config.get("auto_trade", {}).get("min_order_value_krw")
try:
min_order_value = float(raw_min)
except (TypeError, ValueError):
logger.warning(
"[WARNING] min_order_value_krw 설정 누락/비정상 -> 기본값 %d 사용 (raw=%s)", MIN_KRW_ORDER, raw_min
)
min_order_value = float(MIN_KRW_ORDER)
if estimated_value < min_order_value:
msg = f"[매도 건너뜀] {market}\n사유: 최소 주문 금액 미만\n추정 금액: {estimated_value:.0f} KRW < 최소 {min_order_value:.0f} KRW\n매도 수량: {amount:.8f}"
logger.warning(msg)
return {
"market": market,
"side": "sell",
"amount": amount,
"status": "skipped_too_small",
"reason": "min_order_value",
"estimated_value": estimated_value,
"timestamp": time.time(),
}
# ===== 매도 API 안전 검증 (Critical Safety Check) =====
# pyupbit API: sell_market_order(ticker, volume)
# - ticker: 마켓 코드 (예: "KRW-BTC")
# - volume: 매도할 코인 수량 (개수, not KRW)
# 잘못된 사용 예시: sell_market_order("KRW-BTC", 500000) → BTC 500,000개 매도 시도! ❌
# 올바른 사용 예시: sell_market_order("KRW-BTC", 0.01) → BTC 0.01개 매도 ✅
if amount <= 0:
msg = f"[매도 실패] {market}: 비정상 수량 (amount={amount})"
logger.error(msg)
return {
"market": market,
"side": "sell",
"amount": amount,
"status": "failed",
"error": "invalid_amount",
"timestamp": time.time(),
}
# 매도 전 파라미터 검증 로그 (안전장치)
logger.info(
"🔍 [매도 주문 전 검증] %s | 매도 수량=%.8f개 | 현재가=%.2f KRW | 예상 매도액=%.2f KRW",
market,
amount,
current_price,
estimated_value,
)
resp = upbit.sell_market_order(market, amount)
logger.info(
"✅ Upbit 시장가 매도 주문 완료: %s | 수량=%.8f개 | 예상 매도액=%.2f KRW", market, amount, estimated_value
)
if isinstance(resp, dict):
order_uuid = resp.get("uuid")
logger.info("Upbit 주문 응답: uuid=%s", order_uuid)
else:
logger.info("Upbit 주문 응답: %s", resp)
result = {
"market": market,
"side": "sell",
"amount": amount,
"status": "placed",
"response": resp,
"timestamp": time.time(),
}
try:
order_uuid = None
if isinstance(resp, dict):
order_uuid = resp.get("uuid") or resp.get("id") or resp.get("txid")
if order_uuid:
monitor_res = monitor_order_upbit(order_uuid, cfg.upbit_access_key, cfg.upbit_secret_key)
result["monitor"] = monitor_res
result["status"] = monitor_res.get("final_status", result["status"]) or result["status"]
except Exception:
logger.debug("매도 주문 모니터링 중 예외 발생", exc_info=True)
return result
except Exception as e:
logger.exception("Upbit 매도 주문 실패: %s", e)
return {"error": str(e), "status": "failed", "timestamp": time.time()}
def execute_sell_order_with_confirmation(symbol: str, amount: float, cfg: "RuntimeConfig") -> dict:
"""
매도 주문 확인 후 실행
"""
confirm_cfg = cfg.config.get("confirm", {})
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
result = None
if not confirm_via_file:
logger.info("파일 확인 비활성화: 즉시 매도 주문 실행")
result = place_sell_order_upbit(symbol, amount, cfg)
else:
token = _make_confirm_token()
order_info = {"symbol": symbol, "side": "sell", "amount": amount, "timestamp": time.time()}
_write_pending_order(token, order_info)
# Telegram 확인 메시지 전송
if cfg.telegram_parse_mode == "HTML":
msg = f"<b>[확인필요] 자동매도 주문 대기</b>\n"
msg += f"토큰: <code>{token}</code>\n"
msg += f"심볼: <b>{symbol}</b>\n"
msg += f"매도수량: <b>{amount:.8f}</b>\n\n"
msg += f"확인 방법: 파일 생성 -> <code>confirm_{token}</code>\n"
msg += f"타임아웃: {confirm_timeout}"
else:
msg = f"[확인필요] 자동매도 주문 대기\n"
msg += f"토큰: {token}\n"
msg += f"심볼: {symbol}\n"
msg += f"매도수량: {amount:.8f}\n\n"
msg += f"확인 방법: 파일 생성 -> confirm_{token}\n"
msg += f"타임아웃: {confirm_timeout}"
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
logger.info("[%s] 매도 확인 대기 중: 토큰=%s, 타임아웃=%d", symbol, token, confirm_timeout)
confirmed = _check_confirmation(token, confirm_timeout)
if not confirmed:
logger.warning("[%s] 매도 확인 타임아웃: 주문 취소", symbol)
if cfg.telegram_bot_token and cfg.telegram_chat_id:
cancel_msg = f"[주문취소] {symbol} 매도\n사유: 사용자 미확인 (타임아웃)"
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
cancel_msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
else:
logger.info("[%s] 매도 확인 완료: 주문 실행", symbol)
result = place_sell_order_upbit(symbol, amount, cfg)
# 주문 결과 알림
if result and result.get("monitor"):
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두) 및 보유 수량 차감
if result:
trade_status = result.get("status")
monitor = result.get("monitor", {})
monitor_status = monitor.get("final_status")
record_conditions = ["simulated", "filled", "partial", "timeout", "user_not_confirmed"]
if trade_status in record_conditions or monitor_status in record_conditions:
trade_record = {
"symbol": symbol,
"side": "sell",
"amount": amount,
"timestamp": time.time(),
"dry_run": cfg.dry_run,
"result": result,
}
_calculate_and_add_profit_rate(trade_record, symbol, monitor)
from .signals import record_trade
record_trade(trade_record)
# 실전 거래이고, 일부/전부 체결됐다면 holdings에서 수량 차감
if not cfg.dry_run and monitor:
filled_volume = float(monitor.get("filled_volume", 0.0) or 0.0)
final_status = monitor.get("final_status")
if final_status in ("filled", "partial", "timeout") and filled_volume > 0:
from .holdings import update_holding_amount
min_threshold = cfg.config.get("min_amount_threshold", 1e-8)
update_holding_amount(symbol, -filled_volume, HOLDINGS_FILE, min_amount_threshold=min_threshold)
return result
def execute_buy_order_with_confirmation(symbol: str, amount_krw: float, cfg: "RuntimeConfig") -> dict:
"""
매수 주문 확인 후 실행 (매도와 동일한 확인 메커니즘)
Args:
symbol: 거래 심볼
amount_krw: 매수할 KRW 금액
cfg: RuntimeConfig 객체
Returns:
주문 결과 딕셔너리
"""
confirm_cfg = cfg.config.get("confirm", {})
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
result = None
if not confirm_via_file:
logger.info("파일 확인 비활성화: 즉시 매수 주문 실행")
result = place_buy_order_upbit(symbol, amount_krw, cfg)
else:
token = _make_confirm_token()
order_info = {"symbol": symbol, "side": "buy", "amount_krw": amount_krw, "timestamp": time.time()}
_write_pending_order(token, order_info)
# Telegram 확인 메시지 전송
if cfg.telegram_parse_mode == "HTML":
msg = f"<b>[확인필요] 자동매수 주문 대기</b>\n"
msg += f"토큰: <code>{token}</code>\n"
msg += f"심볼: <b>{symbol}</b>\n"
msg += f"매수금액: <b>{amount_krw:,.0f} KRW</b>\n\n"
msg += f"확인 방법: 파일 생성 -> <code>confirm_{token}</code>\n"
msg += f"타임아웃: {confirm_timeout}"
else:
msg = f"[확인필요] 자동매수 주문 대기\n"
msg += f"토큰: {token}\n"
msg += f"심볼: {symbol}\n"
msg += f"매수금액: {amount_krw:,.0f} KRW\n\n"
msg += f"확인 방법: 파일 생성 -> confirm_{token}\n"
msg += f"타임아웃: {confirm_timeout}"
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
logger.info("[%s] 매수 확인 대기 중: 토큰=%s, 타임아웃=%d", symbol, token, confirm_timeout)
confirmed = _check_confirmation(token, confirm_timeout)
if not confirmed:
logger.warning("[%s] 매수 확인 타임아웃: 주문 취소", symbol)
if cfg.telegram_bot_token and cfg.telegram_chat_id:
cancel_msg = f"[주문취소] {symbol} 매수\n사유: 사용자 미확인 (타임아웃)"
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
cancel_msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
else:
logger.info("[%s] 매수 확인 완료: 주문 실행", symbol)
result = place_buy_order_upbit(symbol, amount_krw, cfg)
# 주문 결과 알림
if result and result.get("monitor"):
notify_order_result(symbol, result["monitor"], cfg.config, cfg.telegram_bot_token, cfg.telegram_chat_id)
# 주문 성공 시 거래 기록 (실제/시뮬레이션 모두)
if result:
trade_status = result.get("status")
monitor_result = result.get("monitor", {})
monitor_status = monitor_result.get("final_status")
# 시뮬레이션, 완전 체결, 부분 체결, 타임아웃, 사용자 미확인 상태일 때 기록
record_conditions = ["simulated", "filled", "partial", "timeout", "user_not_confirmed"]
if trade_status in record_conditions or monitor_status in record_conditions:
trade_record = {
"symbol": symbol,
"side": "buy",
"amount_krw": amount_krw,
"timestamp": time.time(),
"dry_run": cfg.dry_run,
"result": result,
}
from .signals import record_trade
record_trade(trade_record)
# 실전 거래이고 타임아웃/부분체결 시 체결된 수량을 holdings에 반영
if not cfg.dry_run and monitor_result:
filled_volume = float(monitor_result.get("filled_volume", 0.0) or 0.0)
final_status = monitor_result.get("final_status")
if final_status in ("filled", "partial", "timeout") and filled_volume > 0:
try:
# 평균 매수가 계산
last_order = monitor_result.get("last_order", {})
avg_buy_price = 0.0
if isinstance(last_order, dict):
trades = last_order.get("trades", [])
if trades:
total_krw = sum(float(t.get("price", 0)) * float(t.get("volume", 0)) for t in trades)
total_volume = sum(float(t.get("volume", 0)) for t in trades)
if total_volume > 0:
avg_buy_price = total_krw / total_volume
if avg_buy_price <= 0:
# 평균가 계산 실패 시 현재가 사용
from .holdings import get_current_price
avg_buy_price = get_current_price(symbol)
if avg_buy_price > 0:
from .holdings import add_new_holding
if add_new_holding(symbol, avg_buy_price, filled_volume, time.time(), HOLDINGS_FILE):
logger.info(
"[%s] 타임아웃/부분체결 매수 holdings 자동 반영: 체결량=%.8f, 평균가=%.2f",
symbol,
filled_volume,
avg_buy_price,
)
else:
logger.error("[%s] 타임아웃/부분체결 매수 holdings 반영 실패", symbol)
except Exception as e:
logger.exception("[%s] 타임아웃/부분체결 매수 holdings 반영 중 오류: %s", symbol, e)
return result
def monitor_order_upbit(
order_uuid: str,
access_key: str,
secret_key: str,
timeout: int = None,
poll_interval: int = None,
max_retries: int = None,
) -> dict:
if timeout is None:
timeout = int(os.getenv("ORDER_MONITOR_TIMEOUT", "120"))
if poll_interval is None:
poll_interval = int(os.getenv("ORDER_POLL_INTERVAL", "3"))
if max_retries is None:
max_retries = int(os.getenv("ORDER_MAX_RETRIES", "1"))
upbit = pyupbit.Upbit(access_key, secret_key)
start = time.time()
attempts = 0
current_uuid = order_uuid
last_order = None
filled = 0.0
remaining = None
final_status = "unknown"
consecutive_errors = 0
# config에서 max_consecutive_errors 로드 (기본값 5)
max_consecutive_errors = 5
try:
# Note: config는 함수 매개변수로 전달되지 않으므로 환경변수 사용
max_consecutive_errors = int(os.getenv("ORDER_MAX_CONSECUTIVE_ERRORS", "5"))
except ValueError:
max_consecutive_errors = 5
while True:
# 전체 타임아웃 체크 (무한 대기 방지)
if time.time() - start > timeout + 30: # 여유 시간 30초
logger.error("주문 모니터링 강제 종료: 전체 타임아웃 초과")
final_status = "timeout"
break
try:
order = upbit.get_order(current_uuid)
consecutive_errors = 0 # 성공 시 에러 카운터 리셋
last_order = order
state = order.get("state") if isinstance(order, dict) else None
volume = float(order.get("volume", 0)) if isinstance(order, dict) else 0.0
executed = float(order.get("executed_volume", 0) or order.get("filled_volume", 0) or 0.0)
filled = executed
remaining = max(0.0, volume - executed)
if state in ("done", "closed") or remaining <= 0:
final_status = "filled"
break
if state in ("cancel", "cancelled", "rejected"):
final_status = "cancelled"
break
if time.time() - start > timeout:
if attempts < max_retries and remaining and remaining > 0:
attempts += 1
logger.warning("주문 타임아웃: 재시도 %d/%d, 남은량=%.8f", attempts, max_retries, remaining)
try:
original_side = order.get("side")
cancel_resp = upbit.cancel_order(current_uuid)
logger.info("[%s] 주문 취소 시도: %s", order.get("market"), cancel_resp)
# 취소가 완전히 처리될 때까지 잠시 대기 및 확인
time.sleep(3) # 거래소 처리 시간 대기
cancelled_order = upbit.get_order(current_uuid)
if cancelled_order.get("state") not in ("cancel", "cancelled"):
logger.error("[%s] 주문 취소 실패 또는 이미 체결됨. 재시도 중단.", order.get("market"))
final_status = "error" # 또는 "filled" 상태로 재확인 필요
break
# 매수는 재시도하지 않음 (KRW 금액 계산 복잡도 및 리스크)
# 하지만 부분 체결된 수량이 있다면 그대로 유지
if original_side == "bid":
if filled > 0:
logger.warning("매수 주문 타임아웃: 부분 체결(%.8f) 완료, 재시도하지 않습니다.", filled)
else:
logger.warning("매수 주문 타임아웃: 체결 없음, 재시도하지 않습니다.")
final_status = "timeout"
break
# 매도만 시장가로 재시도
elif original_side == "ask":
logger.info("[%s] 취소 확인 후 시장가 매도 재시도", order.get("market"))
now_resp = upbit.sell_market_order(order.get("market", ""), remaining)
current_uuid = now_resp.get("uuid") if isinstance(now_resp, dict) else None
continue
except Exception as e:
logger.exception("재시도 주문 중 오류: %s", e)
final_status = "error"
break
else:
final_status = "timeout"
break
time.sleep(poll_interval)
except Exception as e:
consecutive_errors += 1
logger.error("주문 모니터링 중 오류 (%d/%d): %s", consecutive_errors, max_consecutive_errors, e)
if consecutive_errors >= max_consecutive_errors:
logger.error("주문 모니터링 중단: 연속 에러 %d회 초과", max_consecutive_errors)
final_status = "error"
break
if time.time() - start > timeout:
final_status = "error"
break
# 에러 발생 시 잠시 대기 후 재시도
time.sleep(min(poll_interval * 2, 10))
return {
"final_status": final_status,
"attempts": attempts,
"filled_volume": filled,
"remaining_volume": remaining,
"last_order": last_order,
"last_checked": time.time(),
}

68
src/retry_utils.py Normal file
View File

@@ -0,0 +1,68 @@
# src/retry_utils.py
"""Exponential backoff retry decorator for network operations."""
import time
import functools
from typing import Callable, TypeVar, Any
from .common import logger
T = TypeVar("T")
def retry_with_backoff(
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 10.0,
exponential_base: float = 2.0,
exceptions: tuple = (Exception,),
) -> Callable:
"""
Exponential backoff retry decorator.
Args:
max_attempts: Maximum number of retry attempts
base_delay: Initial delay in seconds
max_delay: Maximum delay between retries
exponential_base: Base for exponential calculation
exceptions: Tuple of exceptions to catch and retry
Returns:
Decorated function with retry logic
Example:
@retry_with_backoff(max_attempts=3, base_delay=1.0)
def fetch_data():
return api.get_data()
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> T:
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_attempts:
logger.error("[RETRY] %s 최종 실패 (%d/%d 시도): %s", func.__name__, attempt, max_attempts, e)
break
# Calculate delay with exponential backoff
delay = min(base_delay * (exponential_base ** (attempt - 1)), max_delay)
logger.warning(
"[RETRY] %s 실패 (%d/%d): %s | %.1f초 후 재시도", func.__name__, attempt, max_attempts, e, delay
)
time.sleep(delay)
# If all attempts failed, raise the last exception
if last_exception:
raise last_exception
# This should never happen, but for type safety
raise RuntimeError(f"{func.__name__} failed without exception")
return wrapper
return decorator

843
src/signals.py Normal file
View File

@@ -0,0 +1,843 @@
import os
import time
import json
import inspect
from typing import List
import pandas as pd
import pandas_ta as ta
from datetime import datetime
from .common import logger, FLOAT_EPSILON, HOLDINGS_FILE, TRADES_FILE
from .indicators import fetch_ohlcv, compute_macd_hist, compute_sma, DataFetchError
from .holdings import fetch_holdings_from_upbit, get_current_price
from .notifications import send_telegram, send_telegram_with_retry
from .config import RuntimeConfig # 테스트 환경에서 NameError 방지
def make_trade_record(symbol, side, amount_krw, dry_run, price=None, status="simulated"):
now = float(time.time())
# pandas 타입을 Python native 타입으로 변환 (JSON 직렬화 가능)
if price is not None:
price = float(price)
return {
"symbol": symbol,
"side": side,
"amount_krw": float(amount_krw),
"timestamp": now,
"datetime": datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S"),
"dry_run": bool(dry_run),
"result": {
"market": str(symbol),
"side": str(side),
"amount_krw": float(amount_krw),
"price": price,
"status": str(status),
"timestamp": now,
},
}
def evaluate_sell_conditions(
current_price: float, buy_price: float, max_price: float, holding_info: dict, config: dict = None
) -> dict:
config = config or {}
# auto_trade 설정에서 매도 조건 설정값 로드
auto_trade_config = config.get("auto_trade", {})
loss_threshold = float(auto_trade_config.get("loss_threshold", -5.0)) # 1. 초기 손절 라인
profit_threshold_1 = float(auto_trade_config.get("profit_threshold_1", 10.0)) # 3. 부분 익절 시작 수익률
profit_threshold_2 = float(
auto_trade_config.get("profit_threshold_2", 30.0)
) # 5. 전량 익절 기준 수익률 (높은 구간)
drawdown_1 = float(auto_trade_config.get("drawdown_1", 5.0)) # 2, 4. 트레일링 스탑 하락률
drawdown_2 = float(auto_trade_config.get("drawdown_2", 15.0)) # 5. 트레일링 스탑 하락률 (높은 구간)
# 현재 수익률 및 최고점 대비 하락률 계산 (엡실론 기반 안전한 비교)
profit_rate = ((current_price - buy_price) / buy_price) * 100 if buy_price > FLOAT_EPSILON else 0
max_drawdown = ((current_price - max_price) / max_price) * 100 if max_price > FLOAT_EPSILON else 0
result = {
"status": "hold",
"sell_ratio": 0.0,
"reasons": [],
"profit_rate": profit_rate,
"max_drawdown": max_drawdown,
"set_partial_sell_done": False,
}
# 매도조건 1: 무조건 손절 (매수가 대비 -5% 하락)
if profit_rate <= loss_threshold:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(f"손절(조건1): 수익률 {profit_rate:.2f}% <= {loss_threshold}%")
return result
# 매도조건 3: 수익률 10% 이상 도달 시 1회성 절반 매도
partial_sell_done = holding_info.get("partial_sell_done", False)
if not partial_sell_done and profit_rate >= profit_threshold_1:
result.update(status="stop_loss", sell_ratio=0.5)
result["reasons"].append(f"부분 익절(조건3): 수익률 {profit_rate:.2f}% 달성, 50% 매도")
result["set_partial_sell_done"] = True
return result
# --- 전량 매도 조건 (부분 매도 완료 후 또는 해당 없는 경우) ---
# 최고 수익률 계산 (어느 구간에 있었는지 판단하기 위함)
max_profit_rate = ((max_price - buy_price) / buy_price) * 100 if buy_price > FLOAT_EPSILON else 0
# 매도조건 5: 최고 수익률이 profit_threshold_2 초과 구간 (고수익 구간)
if max_profit_rate > profit_threshold_2:
# 5-2: 수익률이 기준선 이하(<=)로 하락하면 수익 보호 (stop_loss)
if profit_rate <= profit_threshold_2:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(
f"수익률 보호(조건5): 최고 수익률({max_profit_rate:.2f}%) 후 {profit_rate:.2f}%로 하락 (<= {profit_threshold_2}%)"
)
return result
# 5-1: 기준선 위에서 최고점 대비 큰 하락 발생 시 익절 (트레일링)
if max_drawdown <= -drawdown_2:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건5): 최고 수익률({max_profit_rate:.2f}%) 후 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_2}%)"
)
return result
# 매도조건 4: 최고 수익률이 profit_threshold_1 초과 profit_threshold_2 이하 (중간 수익 구간)
elif profit_threshold_1 < max_profit_rate <= profit_threshold_2:
# 4-2: 수익률이 기준선 이하(<=)로 하락하면 수익 보호 (stop_loss)
if profit_rate <= profit_threshold_1:
result.update(status="stop_loss", sell_ratio=1.0)
result["reasons"].append(
f"수익률 보호(조건4): 최고 수익률({max_profit_rate:.2f}%) 후 {profit_rate:.2f}%로 하락 (<= {profit_threshold_1}%)"
)
return result
# 4-1: 수익률이 기준선 위에서 최고점 대비 하락률이 임계 초과 시 익절
if profit_rate > profit_threshold_1 and max_drawdown <= -drawdown_1:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건4): 최고 수익률({max_profit_rate:.2f}%) 후 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)"
)
return result
# 매도조건 2: 최고 수익률이 profit_threshold_1 이하 (저수익 구간 - 부분매도 미실행)
elif max_profit_rate <= profit_threshold_1:
# 저수익 구간에서 기준 이상의 하락(트레일링) 발생 시 익절
if max_drawdown <= -drawdown_1:
result.update(status="profit_taking", sell_ratio=1.0)
result["reasons"].append(
f"트레일링 익절(조건2): 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)"
)
return result
result["reasons"].append(f"홀드 (수익률 {profit_rate:.2f}%, 최고점 대비 하락 {max_drawdown:.2f}%)")
return result
def build_sell_message(symbol: str, sell_result: dict, parse_mode: str = "HTML") -> str:
status = sell_result.get("status", "unknown")
profit = sell_result.get("profit_rate", 0.0)
drawdown = sell_result.get("max_drawdown", 0.0)
ratio = int(sell_result.get("sell_ratio", 0.0) * 100)
reasons = sell_result.get("reasons", [])
reason = reasons[0] if reasons else "사유 없음"
market_url = f"https://upbit.com/exchange?code=CRIX.UPBIT.KRW-{symbol.replace('KRW-', '')}"
if parse_mode == "HTML":
msg = f"<b>🔴 매도 신호: {symbol}</b>\n"
msg += f"상태: <b>{status}</b>\n"
msg += f"수익률: <b>{profit:.2f}%</b>\n"
msg += f"최고점 대비: <b>{drawdown:.2f}%</b>\n"
msg += f"매도 비율: <b>{ratio}%</b>\n"
msg += f"사유: {reason}\n"
msg += f'시장: <a href="{market_url}">Upbit {symbol}</a>'
return msg
msg = f"🔴 매도 신호: {symbol}\n"
msg += f"상태: {status}\n"
msg += f"수익률: {profit:.2f}%\n"
msg += f"최고점 대비: {drawdown:.2f}%\n"
msg += f"매도 비율: {ratio}%\n"
msg += f"사유: {reason}\n"
msg += f"시장: {market_url}"
return msg
def _adjust_sell_ratio_for_min_order(
symbol: str, total_amount: float, sell_ratio: float, current_price: float, config: dict
) -> float:
"""
부분 매도 시 최소 주문 금액과 수수료를 고려하여 매도 비율을 조정합니다.
Decimal을 사용하여 부동소수점 오차를 방지합니다.
매도할 금액 또는 남는 금액이 최소 주문 금액 미만일 경우 전량 매도(1.0)로 조정합니다.
"""
if not (0 < sell_ratio < 1):
return sell_ratio
from decimal import Decimal, ROUND_DOWN
auto_trade_cfg = config.get("auto_trade", {})
min_order_value = float(auto_trade_cfg.get("min_order_value_krw", 5000))
fee_margin = float(auto_trade_cfg.get("fee_safety_margin_pct", 0.05)) / 100.0
# Decimal로 변환하여 정밀 계산 (부동소수점 오차 방지)
d_total = Decimal(str(total_amount))
d_ratio = Decimal(str(sell_ratio))
d_price = Decimal(str(current_price))
d_fee = Decimal(str(1 - fee_margin))
# 매도할 수량 계산 (소수점 8자리까지, 내림)
d_to_sell = (d_total * d_ratio).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
d_remaining = d_total - d_to_sell
# KRW 금액 계산 (수수료 적용)
value_to_sell = float(d_to_sell * d_price * d_fee)
value_remaining = float(d_remaining * d_price * d_fee)
if value_to_sell < min_order_value or value_remaining < min_order_value:
logger.info(
"[%s] 부분 매도(%.0f%%) 조건 충족했으나, 최소 주문 금액(%.0f KRW) 문제로 "
"전량 매도로 전환합니다. (예상 매도액: %.2f, 예상 잔여액: %.2f)",
symbol,
sell_ratio * 100,
min_order_value,
value_to_sell,
value_remaining,
)
return 1.0
return sell_ratio
def record_trade(trade: dict, trades_file: str = TRADES_FILE, critical: bool = True) -> None:
"""
거래 기록을 원자적으로 저장합니다.
Args:
trade: 거래 정보 딕셔너리
trades_file: 저장 파일 경로
critical: True면 저장 실패 시 예외 발생, False면 경고만 로그
"""
try:
trades = []
if os.path.exists(trades_file):
# 파일 읽기 (with 블록 종료 후 파일 핸들 자동 닫힘)
try:
with open(trades_file, "r", encoding="utf-8") as f:
trades = json.load(f)
except json.JSONDecodeError as e:
# with 블록 밖에서 파일 핸들이 닫힌 후 백업 시도
logger.warning("거래기록 파일 손상 감지, 백업 후 새로 시작: %s", e)
backup_file = f"{trades_file}.corrupted.{int(time.time())}"
try:
os.rename(trades_file, backup_file)
logger.info("손상된 파일 백업: %s", backup_file)
except Exception as backup_err:
logger.error("백업 실패: %s", backup_err)
trades = []
trades.append(trade)
# 원자적 쓰기 (임시 파일 사용)
temp_file = f"{trades_file}.tmp"
with open(temp_file, "w", encoding="utf-8") as f:
json.dump(trades, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno()) # 디스크 동기화 보장
os.replace(temp_file, trades_file)
logger.debug("거래기록 저장 성공: %s", trades_file)
except Exception as e:
logger.error("거래기록 저장 실패: %s", e)
if critical:
# 매도 거래는 반드시 기록되어야 하므로 예외 발생
raise RuntimeError(f"[CRITICAL] 거래 기록 저장 실패: {e}") from e
# critical=False인 경우 경고만 로그 (dry_run 시뮬레이션 등)
def _update_df_with_realtime_price(df: pd.DataFrame, symbol: str, timeframe: str, buffer: list) -> pd.DataFrame:
"""
진행 중인 마지막 캔들 데이터를 실시간 현재가로 업데이트합니다.
"""
try:
from datetime import datetime, timezone
current_price = get_current_price(symbol)
if not (current_price > 0 and df is not None and not df.empty):
return df
last_candle_time = df.index[-1]
now = datetime.now(timezone.utc)
# 봉 주기를 초 단위로 변환
interval_seconds = 0
if "h" in timeframe:
interval_seconds = int(timeframe.replace("h", "")) * 3600
elif "m" in timeframe:
interval_seconds = int(timeframe.replace("m", "")) * 60
elif "d" in timeframe:
interval_seconds = int(timeframe.replace("d", "")) * 86400
if interval_seconds > 0:
if last_candle_time.tzinfo is None:
last_candle_time = last_candle_time.tz_localize(timezone.utc)
next_candle_time = last_candle_time + pd.Timedelta(seconds=interval_seconds)
if last_candle_time <= now < next_candle_time:
df.loc[df.index[-1], "close"] = current_price
df.loc[df.index[-1], "high"] = max(df.loc[df.index[-1], "high"], current_price)
df.loc[df.index[-1], "low"] = min(df.loc[df.index[-1], "low"], current_price)
buffer.append(f"실시간 캔들 업데이트 적용: close={current_price:.2f}")
except Exception as e:
buffer.append(f"warning: 실시간 캔들 업데이트 실패: {e}")
return df
def _prepare_data_and_indicators(
symbol: str, timeframe: str, candle_count: int, indicators: dict, buffer: list
) -> dict | None:
"""데이터를 가져오고 모든 기술적 지표를 계산합니다."""
try:
df = fetch_ohlcv(symbol, timeframe, limit=candle_count, log_buffer=buffer)
df = _update_df_with_realtime_price(df, symbol, timeframe, buffer)
if df.empty or len(df) < 3:
buffer.append(f"지표 계산에 충분한 데이터 없음: {symbol}")
return None
ind = indicators or {}
macd_fast = int(ind.get("macd_fast", 12))
macd_slow = int(ind.get("macd_slow", 26))
macd_signal = int(ind.get("macd_signal", 9))
adx_length = int(ind.get("adx_length", 14))
sma_short_len = int(ind.get("sma_short", 5))
sma_long_len = int(ind.get("sma_long", 200))
macd_df = ta.macd(df["close"], fast=macd_fast, slow=macd_slow, signal=macd_signal)
hist_cols = [c for c in macd_df.columns if "MACDh" in c or "hist" in c.lower()]
macd_cols = [c for c in macd_df.columns if ("MACD" in c and c not in hist_cols and not c.lower().endswith("s"))]
signal_cols = [c for c in macd_df.columns if ("MACDs" in c or c.lower().endswith("s") or "signal" in c.lower())]
if not macd_cols or not signal_cols:
raise RuntimeError("MACD 컬럼을 찾을 수 없습니다.")
sma_short = compute_sma(df["close"], sma_short_len, log_buffer=buffer)
sma_long = compute_sma(df["close"], sma_long_len, log_buffer=buffer)
adx_df = ta.adx(df["high"], df["low"], df["close"], length=adx_length)
adx_cols = [c for c in adx_df.columns if "ADX" in c.upper()]
return {
"df": df,
"macd_line": macd_df[macd_cols[0]].dropna(),
"signal_line": macd_df[signal_cols[0]].dropna(),
"sma_short": sma_short,
"sma_long": sma_long,
"adx": adx_df[adx_cols[0]].dropna() if adx_cols else pd.Series([]),
"indicators_config": {
"adx_threshold": float(ind.get("adx_threshold", 25)),
"sma_short_len": sma_short_len,
"sma_long_len": sma_long_len,
},
}
except Exception as e:
buffer.append(f"warning: 지표 준비 실패: {e}")
logger.warning(f"[{symbol}] 지표 준비 중 오류 발생: {e}")
return None
def _evaluate_buy_conditions(data: dict) -> dict:
"""계산된 지표를 바탕으로 매수 조건을 평가하고 원시 데이터를 반환합니다."""
if not data or len(data.get("macd_line", [])) < 2 or len(data.get("signal_line", [])) < 2:
return {"matches": [], "data_points": {}}
# 지표 값 추출
raw_data = {
"prev_macd": data["macd_line"].iloc[-2],
"curr_macd": data["macd_line"].iloc[-1],
"prev_signal": data["signal_line"].iloc[-2],
"curr_signal": data["signal_line"].iloc[-1],
"close": data["df"]["close"].iloc[-1],
}
sma_short = data["sma_short"].dropna()
sma_long = data["sma_long"].dropna()
raw_data.update(
{
"curr_sma_short": sma_short.iloc[-1] if len(sma_short) >= 1 else None,
"prev_sma_short": sma_short.iloc[-2] if len(sma_short) >= 2 else None,
"curr_sma_long": sma_long.iloc[-1] if len(sma_long) >= 1 else None,
"prev_sma_long": sma_long.iloc[-2] if len(sma_long) >= 2 else None,
}
)
adx = data["adx"].dropna()
raw_data.update(
{"curr_adx": adx.iloc[-1] if len(adx) >= 1 else None, "prev_adx": adx.iloc[-2] if len(adx) >= 2 else None}
)
adx_threshold = data["indicators_config"]["adx_threshold"]
# 조건 정의
cross_macd_signal = (
raw_data["prev_macd"] < raw_data["prev_signal"] and raw_data["curr_macd"] > raw_data["curr_signal"]
)
cross_macd_zero = raw_data["prev_macd"] < 0 and raw_data["curr_macd"] > 0
macd_cross_ok = cross_macd_signal or cross_macd_zero
macd_above_signal = raw_data["curr_macd"] > raw_data["curr_signal"]
sma_condition = (
raw_data["curr_sma_short"] is not None
and raw_data["curr_sma_long"] is not None
and raw_data["curr_sma_short"] > raw_data["curr_sma_long"]
)
cross_sma = (
raw_data["prev_sma_short"] is not None
and raw_data["prev_sma_long"] is not None
and raw_data["prev_sma_short"] < raw_data["prev_sma_long"]
and raw_data["curr_sma_short"] is not None
and raw_data["curr_sma_long"] is not None
and raw_data["curr_sma_short"] > raw_data["curr_sma_long"]
)
adx_ok = raw_data["curr_adx"] is not None and raw_data["curr_adx"] > adx_threshold
cross_adx = (
raw_data["prev_adx"] is not None
and raw_data["curr_adx"] is not None
and raw_data["prev_adx"] <= adx_threshold
and raw_data["curr_adx"] > adx_threshold
)
# 조건 매칭
matches = []
if macd_cross_ok and sma_condition and adx_ok:
matches.append("매수조건1")
if cross_sma and macd_above_signal and adx_ok:
matches.append("매수조건2")
if cross_adx and sma_condition and macd_above_signal:
matches.append("매수조건3")
return {
"matches": matches,
"data_points": raw_data,
"conditions": {
"macd_cross_ok": macd_cross_ok,
"sma_condition": sma_condition,
"cross_sma": cross_sma,
"macd_above_signal": macd_above_signal,
"adx_ok": adx_ok,
"cross_adx": cross_adx,
},
}
def _handle_buy_signal(symbol: str, evaluation: dict, cfg: "RuntimeConfig"):
"""매수 신호를 처리하고, 알림을 보내거나 자동 매수를 실행합니다."""
if not evaluation.get("matches"):
return None
data = evaluation.get("data_points", {})
close_price = data.get("close")
if close_price is None:
return None
# 포매팅 헬퍼
fmt_val = lambda v, p: f"{v:.{p}f}" if v is not None else "N/A"
# 메시지 생성
text = f"매수 신호발생: {symbol} -> {', '.join(evaluation['matches'])}\n가격: {close_price:.8f}\n"
text += f"[MACD] curr/sig: {fmt_val(data.get('curr_macd'), 6)}/{fmt_val(data.get('curr_signal'), 6)}\n"
text += f"[SMA] short/long: {fmt_val(data.get('curr_sma_short'), 1)}/{fmt_val(data.get('curr_sma_long'), 1)}\n"
text += f"[ADX] curr: {fmt_val(data.get('curr_adx'), 4)}"
result = {"telegram": text, "buy_order": None}
trade_recorded = False
amount_krw = float(cfg.config.get("auto_trade", {}).get("buy_amount_krw", 0) or 0)
if cfg.dry_run:
trade = make_trade_record(symbol, "buy", amount_krw, True, price=close_price, status="simulated")
record_trade(trade, TRADES_FILE)
trade_recorded = True
elif cfg.trading_mode == "auto_trade":
auto_trade_cfg = cfg.config.get("auto_trade", {})
can_auto_buy = auto_trade_cfg.get("buy_enabled", False) and amount_krw > 0
if auto_trade_cfg.get("require_env_confirm", True):
can_auto_buy = can_auto_buy and os.getenv("AUTO_TRADE_ENABLED") == "1"
if auto_trade_cfg.get("allowed_symbols", []) and symbol not in auto_trade_cfg["allowed_symbols"]:
can_auto_buy = False
if can_auto_buy:
from .holdings import get_upbit_balances
try:
balances = get_upbit_balances(cfg)
if (balances or {}).get("KRW", 0) < amount_krw:
logger.warning(f"[{symbol}] 잔고 부족으로 매수 건너뜜")
# ... (잔고 부족 알림)
return result
except Exception as e:
logger.warning(f"[{symbol}] 잔고 확인 실패: {e}")
from .order import execute_buy_order_with_confirmation
buy_result = execute_buy_order_with_confirmation(symbol=symbol, amount_krw=amount_krw, cfg=cfg)
result["buy_order"] = buy_result
monitor = buy_result.get("monitor", {})
if (
monitor.get("final_status") in ["filled", "partial", "timeout"]
and float(monitor.get("filled_volume", 0)) > 0
):
trade_recorded = True
# ... (매수 후 처리 로직: holdings 업데이트 및 거래 기록)
if not trade_recorded and not cfg.dry_run:
trade = make_trade_record(symbol, "buy", amount_krw, False, price=close_price, status="notified")
record_trade(trade, TRADES_FILE)
return result
def _process_symbol_core(symbol: str, cfg: "RuntimeConfig", indicators: dict = None) -> dict:
result = {"symbol": symbol, "summary": [], "telegram": None, "error": None}
buffer = []
try:
timeframe = cfg.timeframe
candle_count = cfg.candle_count
indicator_timeframe = cfg.indicator_timeframe
use_tf = indicator_timeframe or timeframe
data = _prepare_data_and_indicators(symbol, use_tf, candle_count, indicators, buffer)
result["summary"].extend(buffer)
if data is None:
result["error"] = "data_preparation_failed"
return result
evaluation = _evaluate_buy_conditions(data)
# 상세 로그 생성
if evaluation["matches"]:
result["summary"].append(f"매수 신호발생: {symbol} -> {', '.join(evaluation['matches'])}")
else:
result["summary"].append("조건 미충족: 매수조건 없음")
if evaluation["data_points"]:
dp = evaluation["data_points"]
c = evaluation["conditions"]
result["summary"].append(f"[조건1 {'충족' if c['macd_cross_ok'] and c['sma_condition'] else '미충족'}]")
result["summary"].append(
f"[조건2 {'충족' if c['cross_sma'] and c['macd_above_signal'] and c['adx_ok'] else '미충족'}]"
)
result["summary"].append(
f"[조건3 {'충족' if c['cross_adx'] and c['sma_condition'] and c['macd_above_signal'] else '미충족'}]"
)
if evaluation["matches"]:
signal_result = _handle_buy_signal(symbol, evaluation, cfg)
if signal_result:
result.update(signal_result)
except DataFetchError as e:
result["summary"].append(f"데이터 수집 실패: {symbol} -> {e}")
result["error"] = "data_fetch_error"
except Exception as e:
logger.exception(f"심볼 처리 중 오류: {symbol} -> {e}")
result["error"] = str(e)
result["summary"].append(f"심볼 처리 중 오류: {symbol} -> {e}")
return result
def process_symbol(*args, **kwargs) -> dict:
"""신규 + 레거시 시그니처 동시 지원 래퍼.
레거시 형태:
process_symbol(symbol, timeframe, limit, token, chat_id, dry_run, indicators=None, indicator_timeframe=None)
신규 형태:
process_symbol(symbol, cfg, indicators=None)
process_symbol(symbol, cfg=cfg, indicators=...) # 혼합 (위치+키워드)
"""
# cfg가 키워드 인자로 전달된 경우 (신규 방식 - 가장 일반적)
if "cfg" in kwargs:
# symbol은 위치 인자 또는 키워드 인자
symbol = args[0] if len(args) > 0 else kwargs["symbol"]
cfg = kwargs["cfg"]
indicators = kwargs.get("indicators")
return _process_symbol_core(symbol, cfg, indicators=indicators)
# 위치 인자로 호출된 경우
if len(args) >= 6 and isinstance(args[1], str) and not hasattr(args[1], "config"):
# 레거시 형태 (6개 이상 인자 + 두 번째가 문자열)
symbol = args[0]
timeframe = args[1]
limit = args[2]
token = args[3]
chat_id = args[4]
dry_run = args[5]
indicators = kwargs.get("indicators")
indicator_timeframe = kwargs.get("indicator_timeframe") or timeframe
from .config import RuntimeConfig, get_default_config
base_cfg = get_default_config()
cfg = RuntimeConfig(
timeframe=timeframe,
indicator_timeframe=indicator_timeframe,
candle_count=limit or base_cfg.get("candle_count", 200),
symbol_delay=base_cfg.get("symbol_delay", 1.0),
interval=60,
loop=False,
dry_run=dry_run,
max_threads=1,
telegram_parse_mode="HTML",
trading_mode="signal_only",
telegram_bot_token=token,
telegram_chat_id=chat_id,
upbit_access_key=None,
upbit_secret_key=None,
aggregate_alerts=False,
benchmark=False,
telegram_test=False,
config=base_cfg,
)
return _process_symbol_core(symbol, cfg, indicators=indicators)
elif len(args) >= 2:
# 신규 형태 (위치 인자: symbol, cfg, [indicators])
symbol = args[0]
cfg = args[1]
indicators = kwargs.get("indicators") if len(args) < 3 else args[2]
return _process_symbol_core(symbol, cfg, indicators=indicators)
else:
raise ValueError(f"process_symbol: 잘못된 인자 형식 (args={args}, kwargs={kwargs})")
def _process_sell_decision(
symbol: str, holding_info: dict, sell_result: dict, current_price: float, cfg: RuntimeConfig, config: dict
) -> int:
"""
Handles the logic for executing a sell order and sending notifications based on a sell decision.
Returns 1 if a sell signal was processed, 0 otherwise.
"""
from .order import execute_sell_order_with_confirmation
telegram_token = cfg.telegram_bot_token
telegram_chat_id = cfg.telegram_chat_id
dry_run = cfg.dry_run
# 부분 매도 플래그 설정: dry_run 모드일 때만 즉시 저장 (실전 모드는 주문 체결 후 저장)
if sell_result.get("set_partial_sell_done", False) and dry_run:
from src.holdings import set_holding_field
set_holding_field(symbol, "partial_sell_done", True, HOLDINGS_FILE)
logger.info("[%s] partial_sell_done 플래그 설정 완료 (dry_run)", symbol)
if sell_result["sell_ratio"] > 0:
# 매도 조건 충족 시 항상 초기 알림 전송 (모든 모드)
if telegram_token and telegram_chat_id:
from .signals import build_sell_message
msg = build_sell_message(symbol, sell_result, parse_mode=cfg.telegram_parse_mode or "HTML")
send_telegram(
telegram_token,
telegram_chat_id,
msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
# auto_trade + 실전일 때만 실제 주문 로직 수행
if cfg.trading_mode == "auto_trade" and not dry_run:
total_amount = float(holding_info.get("amount", 0))
sell_ratio = float(sell_result.get("sell_ratio", 0.0) or 0.0)
# 최소 주문 금액/수수료 고려하여 매도 비율 보정
sell_ratio = _adjust_sell_ratio_for_min_order(symbol, total_amount, sell_ratio, current_price, config)
amount_to_sell = total_amount * sell_ratio
if amount_to_sell > 0:
logger.info(
"[%s] 자동 매도 조건 충족: 매도 주문 시작 (총 수량: %.8f, 매도 비율: %.0f%%, 주문 수량: %.8f)",
symbol,
total_amount,
sell_ratio * 100,
amount_to_sell,
)
sell_order_result = execute_sell_order_with_confirmation(symbol=symbol, amount=amount_to_sell, cfg=cfg)
# 주문 실패/스킵 시 추가 알림 및 재시도 방지
if sell_order_result:
order_status = sell_order_result.get("status")
if order_status in ("skipped_too_small", "failed", "user_not_confirmed"):
error_msg = sell_order_result.get("error", "알 수 없는 오류")
reason = sell_order_result.get("reason", "")
estimated_value = sell_order_result.get("estimated_value", 0)
if telegram_token and telegram_chat_id:
if order_status == "skipped_too_small":
fail_msg = f"[⚠️ 매도 건너뜀] {symbol}\n사유: 최소 주문 금액 미만\n추정 금액: {estimated_value:.0f} KRW\n매도 수량: {amount_to_sell:.8f}\n\n⚠️ Holdings는 그대로 유지됩니다."
elif order_status == "user_not_confirmed":
fail_msg = f"[⚠️ 매도 취소] {symbol}\n사유: 사용자 확인 타임아웃\n매도 수량: {amount_to_sell:.8f}\n\n⚠️ Holdings는 그대로 유지됩니다."
else:
fail_msg = f"[🚨 매도 실패] {symbol}\n사유: {error_msg}\n매도 수량: {amount_to_sell:.8f}\n\n⚠️ Holdings는 그대로 유지됩니다. 수동 확인 필요!"
send_telegram(
telegram_token,
telegram_chat_id,
fail_msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
# 실패한 주문은 signal_count에 포함하지 않음 (다음 주기에 재시도 가능)
return 0
# 부분 매도 플래그 업데이트 로직 추가
if sell_order_result and sell_result.get("set_partial_sell_done"):
monitor_result = sell_order_result.get("monitor", {})
filled_volume = float(monitor_result.get("filled_volume", 0.0) or 0.0)
final_status = monitor_result.get("final_status")
# 주문이 일부라도 체결되었다면 플래그를 저장
if final_status in ("filled", "partial", "timeout") and filled_volume > 0:
from .holdings import set_holding_field
if set_holding_field(symbol, "partial_sell_done", True, holdings_file=HOLDINGS_FILE):
logger.info(
"[%s] 부분 매도(1회성) 완료, partial_sell_done 플래그를 True로 업데이트합니다.", symbol
)
else:
logger.error("[%s] partial_sell_done 플래그 업데이트에 실패했습니다.", symbol)
else:
# _adjust_sell_ratio_for_min_order에서 전량 매도로 조정되었으나 amount_to_sell이 0인 경우
if telegram_token and telegram_chat_id:
skip_msg = f"[매도 건너뜀] {symbol}\n사유: 매도 수량 계산 오류 (amount_to_sell = 0)\n총 수량: {total_amount:.8f}"
send_telegram(
telegram_token,
telegram_chat_id,
skip_msg,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
logger.warning("[%s] 매도 조건 충족했으나 amount_to_sell=0으로 계산됨", symbol)
return 1
return 0
def _check_sell_logic(holdings: dict, cfg: RuntimeConfig, config: dict, check_type: str) -> tuple[list[dict], int]:
"""
Generic function to check sell conditions based on type ('stop_loss' or 'profit_taking').
"""
results = []
sell_signal_count = 0
valid_statuses = []
if check_type == "stop_loss":
# 손절(1시간): 조건1(기본손절) + 조건3(부분익절) + 조건4-2, 5-2(수익률 보호)
valid_statuses = ["stop_loss"]
elif check_type == "profit_taking":
# 익절(4시간): 조건2, 4-1, 5-1(트레일링 스탑)
valid_statuses = ["profit_taking"]
for symbol, holding_info in holdings.items():
try:
current_price = get_current_price(symbol)
if current_price <= 0:
logger.warning("[%s] 현재가 조회 실패, 매도 검사 건너뜀", symbol)
continue
buy_price = float(holding_info.get("buy_price", 0))
max_price = float(holding_info.get("max_price", current_price))
if buy_price <= 0:
logger.warning("[%s] 매수가 정보 없음, 매도 검사 건너뜀", symbol)
continue
sell_result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info, config)
log_msg = (
f"[{symbol}] {check_type} 검사 - "
f"현재가: {current_price:.2f}, 매수가: {buy_price:.2f}, 최고가: {max_price:.2f}, "
f"수익률: {sell_result['profit_rate']:.2f}%, 최고점대비: {sell_result['max_drawdown']:.2f}%, "
f"상태: {sell_result['status']} (비율: {sell_result['sell_ratio']*100:.0f}%)"
)
logger.info(log_msg)
result_obj = {
"symbol": symbol,
"status": sell_result["status"],
"sell_ratio": sell_result["sell_ratio"],
"profit_rate": sell_result["profit_rate"],
"max_drawdown": sell_result["max_drawdown"],
"reasons": sell_result["reasons"],
"current_price": current_price,
"buy_price": buy_price,
"max_price": max_price,
"amount": holding_info.get("amount", 0),
}
results.append(result_obj)
if sell_result["status"] in valid_statuses and sell_result["sell_ratio"] > 0:
logger.info("[%s] 매도 조건 충족 (처리 시작): %s", symbol, ", ".join(sell_result["reasons"]))
processed = _process_sell_decision(symbol, holding_info, sell_result, current_price, cfg, config)
if processed > 0:
logger.info("[%s] 매도 조건 처리 완료", symbol)
else:
logger.warning("[%s] 매도 조건 충족했으나 주문 실패/건너뜀", symbol)
sell_signal_count += processed
except Exception as e:
logger.exception("매도 조건 확인 중 오류 (%s): %s", symbol, e)
return results, sell_signal_count
def check_stop_loss_conditions(holdings: dict, cfg: RuntimeConfig, config: dict = None) -> tuple[list[dict], int]:
if config is None and cfg is not None and hasattr(cfg, "config"):
config = cfg.config
if not holdings:
logger.info("보유 정보가 없음 - 손절 조건 검사 건너뜀")
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 손절 조건 검사 완료 (보유 코인 없음)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
return [], 0
results, sell_signal_count = _check_sell_logic(holdings, cfg, config, "stop_loss")
if cfg.telegram_bot_token and cfg.telegram_chat_id and sell_signal_count == 0:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 손절 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
return results, sell_signal_count
def check_profit_taking_conditions(holdings: dict, cfg: RuntimeConfig, config: dict = None) -> tuple[list[dict], int]:
if config is None and cfg is not None and hasattr(cfg, "config"):
config = cfg.config
if not holdings:
logger.info("보유 정보가 없음 - 익절 조건 검사 건너뜀")
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 익절 조건 검사 완료 (보유 코인 없음)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
return [], 0
results, sell_signal_count = _check_sell_logic(holdings, cfg, config, "profit_taking")
if cfg.telegram_bot_token and cfg.telegram_chat_id and sell_signal_count == 0:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 익절 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode or "HTML",
)
return results, sell_signal_count

1
src/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Test package

View File

@@ -0,0 +1,111 @@
"""
경계값 테스트: profit_rate가 정확히 10%, 30%일 때 매도 조건 검증
"""
import pytest
from src.signals import evaluate_sell_conditions
class TestBoundaryConditions:
"""매도 조건의 경계값 테스트"""
def test_profit_rate_exactly_10_percent_triggers_partial_sell(self):
"""수익률이 정확히 10%일 때 부분 매도(조건3) 발생"""
# Given: 매수가 100, 현재가 110 (정확히 10% 수익)
buy_price = 100.0
current_price = 110.0
max_price = 110.0
holding_info = {"partial_sell_done": False}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then
assert result["status"] == "stop_loss" # 부분 익절은 stop_loss (1시간 주기)
assert result["sell_ratio"] == 0.5
assert result["set_partial_sell_done"] is True
assert "부분 익절" in result["reasons"][0]
def test_profit_rate_exactly_30_percent_in_high_zone(self):
"""최고 수익률 30% 초과 구간에서 수익률이 정확히 30%로 떨어질 때"""
# Given: 최고가 135 (35% 수익), 현재가 130 (30% 수익)
buy_price = 100.0
current_price = 130.0
max_price = 135.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 수익률이 30% 이하(<= 30)로 하락하여 조건5-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
def test_profit_rate_below_30_percent_triggers_sell(self):
"""최고 수익률 30% 초과 구간에서 수익률이 30% 미만으로 떨어질 때"""
# Given: 최고가 135 (35% 수익), 현재가 129.99 (29.99% 수익)
buy_price = 100.0
current_price = 129.99
max_price = 135.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 조건5-2 발동 (수익률 30% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
def test_profit_rate_exactly_10_percent_in_mid_zone(self):
"""최고 수익률 10~30% 구간에서 수익률이 정확히 10%일 때"""
# Given: 최고가 120 (20% 수익), 현재가 110 (10% 수익)
buy_price = 100.0
current_price = 110.0
max_price = 120.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 수익률이 10% 이하(<= 10)로 하락하여 조건4-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
def test_profit_rate_below_10_percent_triggers_sell(self):
"""최고 수익률 10~30% 구간에서 수익률이 10% 미만으로 떨어질 때"""
# Given: 최고가 120 (20% 수익), 현재가 109.99 (9.99% 수익)
buy_price = 100.0
current_price = 109.99
max_price = 120.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 조건4-2 발동 (수익률 10% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
def test_partial_sell_already_done_no_duplicate(self):
"""부분 매도 이미 완료된 경우 중복 발동 안됨"""
# Given: 매수가 100, 현재가 110 (10% 수익), 이미 부분 매도 완료
buy_price = 100.0
current_price = 110.0
max_price = 110.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 부분 매도 재발동 안됨
assert result["status"] == "hold"
assert result["sell_ratio"] == 0.0
assert result["set_partial_sell_done"] is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,118 @@
"""
치명적 문제 수정 사항 검증 테스트
- 원자적 파일 쓰기
- API 키 검증
- Decimal 정밀도
"""
import os
import json
import tempfile
import pytest
from decimal import Decimal
from src.holdings import save_holdings, load_holdings
from src.signals import record_trade, _adjust_sell_ratio_for_min_order
from src.config import build_runtime_config
class TestCriticalFixes:
"""치명적 문제 수정 사항 테스트"""
def test_atomic_holdings_save(self, tmp_path):
"""[C-1] holdings.json 원자적 쓰기 검증"""
holdings_file = tmp_path / "test_holdings.json"
# 초기 데이터 저장
initial_data = {"KRW-BTC": {"amount": 0.1, "buy_price": 50000000}}
save_holdings(initial_data, str(holdings_file))
# 파일 존재 및 내용 확인
assert holdings_file.exists()
loaded = load_holdings(str(holdings_file))
assert loaded == initial_data
# 임시 파일이 남아있지 않은지 확인 (원자적 교체 완료)
temp_files = list(tmp_path.glob("*.tmp"))
assert len(temp_files) == 0, "임시 파일이 남아있으면 안됩니다"
def test_trade_record_critical_flag(self, tmp_path):
"""[C-4] 거래 기록 critical 플래그 동작 검증"""
trades_file = tmp_path / "test_trades.json"
# critical=False: 저장 실패 시 예외 발생 안함
trade = {"symbol": "KRW-BTC", "side": "sell", "amount": 0.1}
# 정상 저장
record_trade(trade, str(trades_file), critical=False)
assert trades_file.exists()
# critical=True: 파일 권한 오류 시뮬레이션은 어려우므로 정상 케이스만 검증
trade2 = {"symbol": "KRW-ETH", "side": "buy", "amount": 1.0}
record_trade(trade2, str(trades_file), critical=True)
# 두 거래 모두 기록되었는지 확인
with open(trades_file, "r", encoding="utf-8") as f:
trades = json.load(f)
assert len(trades) == 2
assert trades[0]["symbol"] == "KRW-BTC"
assert trades[1]["symbol"] == "KRW-ETH"
def test_api_key_validation_in_config(self):
"""[C-2] API 키 검증 로직 확인"""
# dry_run=True: API 키 없어도 통과
config_dry = {"dry_run": True, "trading_mode": "auto_trade", "auto_trade": {}}
# 환경변수 없어도 예외 발생 안함
cfg = build_runtime_config(config_dry)
assert cfg.dry_run is True
# dry_run=False + auto_trade: 환경변수 필수 (실제 테스트는 환경변수 설정 필요)
# 여기서는 로직 존재 여부만 확인 (실제 ValueError 발생은 환경 의존적)
def test_decimal_precision_in_sell_ratio(self):
"""[C-3] Decimal을 사용한 부동소수점 오차 방지 검증"""
config = {"auto_trade": {"min_order_value_krw": 5000, "fee_safety_margin_pct": 0.05}}
# 테스트 케이스: 0.5 비율 매도 시 정밀 계산
total_amount = 0.00123456 # BTC
sell_ratio = 0.5
current_price = 50_000_000 # 50M KRW
adjusted_ratio = _adjust_sell_ratio_for_min_order("KRW-BTC", total_amount, sell_ratio, current_price, config)
# 부동소수점 오차 없이 계산되었는지 확인
# 예상 매도액: 0.00123456 * 0.5 * 50M * 0.9995 ≈ 30,863 KRW > 5000 → 0.5 유지
assert adjusted_ratio == 0.5
# 경계 케이스: 잔여액이 최소 금액 미만일 때 전량 매도로 전환
small_amount = 0.0001 # BTC
adjusted_ratio_small = _adjust_sell_ratio_for_min_order("KRW-BTC", small_amount, 0.5, current_price, config)
# 0.0001 * 0.5 * 50M * 0.9995 = 2498.75 < 5000 → 전량 매도(1.0)
assert adjusted_ratio_small == 1.0
def test_corrupted_trades_file_backup(self, tmp_path):
"""[C-4] 손상된 거래 파일 백업 기능 검증"""
trades_file = tmp_path / "corrupted_trades.json"
# 손상된 JSON 파일 생성
with open(trades_file, "w", encoding="utf-8") as f:
f.write("{invalid json content")
# 새 거래 기록 시도 → 손상 파일 백업 후 정상 저장
trade = {"symbol": "KRW-BTC", "side": "sell"}
record_trade(trade, str(trades_file), critical=False)
# 백업 파일 생성 확인
backup_files = list(tmp_path.glob("corrupted_trades.json.corrupted.*"))
assert len(backup_files) > 0, "손상된 파일이 백업되어야 합니다"
# 정상 파일로 복구 확인
with open(trades_file, "r", encoding="utf-8") as f:
trades = json.load(f)
assert len(trades) == 1
assert trades[0]["symbol"] == "KRW-BTC"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,143 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
import pytest
from src.signals import evaluate_sell_conditions
@pytest.fixture
def base_config():
"""Provides the auto_trade part of the configuration."""
return {
"auto_trade": {
"loss_threshold": -5.0,
"profit_threshold_1": 10.0,
"profit_threshold_2": 30.0,
"drawdown_1": 5.0,
"drawdown_2": 15.0,
}
}
# Test cases for the new strategy
def test_stop_loss_initial(base_config):
"""Rule 1: Sell if price drops 5% below buy price."""
res = evaluate_sell_conditions(
current_price=95.0, buy_price=100.0, max_price=100.0, holding_info={}, config=base_config
)
assert res["status"] == "stop_loss"
assert res["sell_ratio"] == 1.0
def test_trailing_stop_small_profit(base_config):
"""Rule 2: In small profit (<= 10%), sell if price drops 5% from high."""
res1 = evaluate_sell_conditions(
current_price=96.0, # +6% profit (just above +5% but below +10%)
buy_price=100.0,
max_price=110.0, # High was +10%
holding_info={"partial_sell_done": False},
config=base_config,
)
# Drawdown is (96-110)/110 = -12.7% which is > 5%
assert res1["status"] == "profit_taking" # Trailing stop classified as profit_taking
assert res1["sell_ratio"] == 1.0
def test_partial_profit_at_10_percent(base_config):
"""Rule 3: At profit == 10%, sell 50%."""
res = evaluate_sell_conditions(
current_price=110.0, # Exactly +10%
buy_price=100.0,
max_price=110.0,
holding_info={"partial_sell_done": False},
config=base_config,
)
assert res["status"] == "stop_loss" # Partial profit classified as stop_loss for 1h check
assert res["sell_ratio"] == 0.5
assert res["set_partial_sell_done"] is True
def test_trailing_stop_medium_profit_by_drawdown(base_config):
"""Rule 4: In mid profit (10-30%), sell if price drops 5% from high."""
res = evaluate_sell_conditions(
current_price=123.0, # +23% profit
buy_price=100.0,
max_price=130.0, # High was +30%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (123-130)/130 = -5.38% which is < -5%
assert res["status"] == "profit_taking" # Trailing stop classified as profit_taking for 4h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_medium_profit_by_floor(base_config):
"""Rule 4: In mid profit (10-30%), sell if profit drops to 10%."""
res = evaluate_sell_conditions(
current_price=110.0, # Profit drops to 10%
buy_price=100.0,
max_price=125.0, # High was +25%
holding_info={"partial_sell_done": True},
config=base_config,
)
assert res["status"] == "stop_loss" # Profit protection classified as stop_loss for 1h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_high_profit_by_drawdown(base_config):
"""Rule 5: In high profit (>30%), sell if price drops 15% from high."""
res = evaluate_sell_conditions(
current_price=135.0, # +35% profit
buy_price=100.0,
max_price=160.0, # High was +60%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (135-160)/160 = -15.625% which is < -15%
assert res["status"] == "profit_taking" # Trailing stop classified as profit_taking for 4h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_high_profit_by_floor(base_config):
"""Rule 5: In high profit (>30%), sell if profit drops to 30%."""
res = evaluate_sell_conditions(
current_price=130.0, # Profit drops to 30%
buy_price=100.0,
max_price=150.0, # High was +50%
holding_info={"partial_sell_done": True},
config=base_config,
)
assert res["status"] == "stop_loss" # Profit protection classified as stop_loss for 1h check
assert res["sell_ratio"] == 1.0
def test_hold_high_profit(base_config):
"""Rule 6: Hold if profit > 30% and drawdown is less than 15%."""
res = evaluate_sell_conditions(
current_price=140.0, # +40% profit
buy_price=100.0,
max_price=150.0, # High was +50%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (140-150)/150 = -6.67% which is > -15%
assert res["status"] == "hold"
assert res["sell_ratio"] == 0.0
def test_hold_medium_profit(base_config):
"""Hold if profit is 10-30% and drawdown is less than 5%."""
res = evaluate_sell_conditions(
current_price=128.0, # +28% profit
buy_price=100.0,
max_price=130.0, # High was +30%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (128-130)/130 = -1.5% which is > -5%
assert res["status"] == "hold"
assert res["sell_ratio"] == 0.0

74
src/tests/test_helpers.py Normal file
View File

@@ -0,0 +1,74 @@
"""Test helper functions used primarily in test scenarios."""
import inspect
import sys
import os
# Add parent directory to path to import from src
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from src.common import logger
from src.signals import process_symbol
from src.notifications import send_telegram
def safe_send_telegram(bot_token: str, chat_id: str, text: str, **kwargs) -> bool:
"""Flexibly call send_telegram even if monkeypatched version has a simpler signature.
Inspects the target callable and only passes accepted parameters."""
func = send_telegram
try:
sig = inspect.signature(func)
accepted = sig.parameters.keys()
call_kwargs = {}
# positional mapping
params = list(accepted)
pos_args = [bot_token, chat_id, text]
for i, val in enumerate(pos_args):
if i < len(params):
call_kwargs[params[i]] = val
# optional kwargs filtered
for k, v in kwargs.items():
if k in accepted:
call_kwargs[k] = v
return func(**call_kwargs)
except Exception:
# Fallback positional
try:
return func(bot_token, chat_id, text)
except Exception:
return False
def check_and_notify(
exchange: str,
symbol: str,
timeframe: str,
telegram_token: str,
telegram_chat_id: str,
limit: int = 200,
dry_run: bool = True,
):
"""Compatibility helper used by tests: run processing for a single symbol and send notification if needed.
exchange parameter is accepted for API compatibility but not used (we use pyupbit internally).
"""
try:
res = process_symbol(
symbol,
timeframe,
limit,
telegram_token,
telegram_chat_id,
dry_run,
indicators=None,
indicator_timeframe=None,
)
# If a telegram message was returned from process_symbol, send it (unless dry_run)
if res.get("telegram"):
if dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
else:
if telegram_token and telegram_chat_id:
safe_send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False)
except Exception as e:
logger.exception("check_and_notify 오류: %s", e)

93
src/tests/test_main.py Normal file
View File

@@ -0,0 +1,93 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
import builtins
import types
import pandas as pd
import pytest
import main
from .test_helpers import check_and_notify, safe_send_telegram
def test_compute_macd_hist_monkeypatch(monkeypatch):
# Arrange: monkeypatch pandas_ta.macd to return a DataFrame with MACDh column
dummy_macd = pd.DataFrame({"MACDh_12_26_9": [None, 0.5, 1.2, 2.3]})
def fake_macd(series, fast, slow, signal):
return dummy_macd
monkeypatch.setattr(main.ta, "macd", fake_macd)
close = pd.Series([1, 2, 3, 4])
# Act: import directly from indicators
from src.indicators import compute_macd_hist
hist = compute_macd_hist(close)
# Assert
assert isinstance(hist, pd.Series)
assert list(hist.dropna()) == [0.5, 1.2, 2.3]
def test_check_and_notify_positive_sends(monkeypatch):
# Prepare a fake OHLCV DataFrame with required OHLCV columns
idx = pd.date_range(end=pd.Timestamp.now(), periods=250, freq="h")
df = pd.DataFrame(
{
"open": list(range(100, 350)),
"high": list(range(105, 355)),
"low": list(range(95, 345)),
"close": list(range(100, 350)),
"volume": [1000] * 250,
},
index=idx,
)
# Monkeypatch at the point of use: src.signals imports from indicators
from src import signals
# Patch fetch_ohlcv to return complete OHLCV data
monkeypatch.setattr(signals, "fetch_ohlcv", lambda symbol, timeframe, limit=200, log_buffer=None: df)
# Fake pandas_ta.macd to return MACD crossover (signal cross)
def fake_macd(close, fast=12, slow=26, signal=9):
macd_df = pd.DataFrame(index=close.index)
# Create crossover: prev < signal, curr > signal
macd_values = [-0.5] * (len(close) - 1) + [1.5] # Last value crosses above
signal_values = [0.5] * len(close) # Constant signal line
macd_df["MACD_12_26_9"] = pd.Series(macd_values, index=close.index)
macd_df["MACDs_12_26_9"] = pd.Series(signal_values, index=close.index)
macd_df["MACDh_12_26_9"] = pd.Series([v - s for v, s in zip(macd_values, signal_values)], index=close.index)
return macd_df
monkeypatch.setattr(signals.ta, "macd", fake_macd)
# Fake pandas_ta.adx to return valid ADX data
def fake_adx(high, low, close, length=14):
adx_df = pd.DataFrame(index=close.index)
adx_df[f"ADX_{length}"] = pd.Series([30.0] * len(close), index=close.index)
return adx_df
monkeypatch.setattr(signals.ta, "adx", fake_adx)
# Capture calls to safe_send_telegram
called = {"count": 0}
def fake_safe_send(token, chat_id, text, **kwargs):
called["count"] += 1
return True
# Monkeypatch test_helpers module
from . import test_helpers
monkeypatch.setattr(test_helpers, "safe_send_telegram", fake_safe_send)
# Act: call check_and_notify (not dry-run)
check_and_notify("upbit", "KRW-BTC", "1h", "token", "chat", limit=10, dry_run=False)
# Assert: safe_send_telegram was called
assert called["count"] == 1

160
src/threading_utils.py Normal file
View File

@@ -0,0 +1,160 @@
import time
import threading
from typing import List
from .config import RuntimeConfig
from .common import logger
from .signals import process_symbol
from .notifications import send_telegram
def run_sequential(symbols: List[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
logger.info("순차 처리 시작 (심볼 수=%d)", len(symbols))
alerts = []
buy_signal_count = 0
for i, sym in enumerate(symbols):
try:
res = process_symbol(sym, cfg=cfg)
for line in res.get("summary", []):
logger.info(line)
if res.get("telegram"):
buy_signal_count += 1
if cfg.dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
else:
# dry_run이 아닐 때는 콘솔에 메시지 출력하지 않음
pass
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
res["telegram"],
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
alerts.append({"symbol": sym, "text": res["telegram"]})
except Exception as e:
logger.exception("심볼 처리 오류: %s -> %s", sym, e)
if i < len(symbols) - 1 and cfg.symbol_delay is not None:
logger.debug("다음 심볼까지 %.2f초 대기", cfg.symbol_delay)
time.sleep(cfg.symbol_delay)
if aggregate_enabled and len(alerts) > 1:
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
summary_lines += [f"- {a['symbol']}" for a in alerts]
summary_text = "\n".join(summary_lines)
if cfg.dry_run:
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
else:
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
summary_text,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
if cfg.telegram_bot_token and cfg.telegram_chat_id and not any(a.get("text") for a in alerts):
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
return buy_signal_count
def run_with_threads(symbols: List[str], cfg: RuntimeConfig, aggregate_enabled: bool = False):
logger.info(
"병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)",
len(symbols),
cfg.max_threads or 0,
cfg.symbol_delay or 0.0,
)
semaphore = threading.Semaphore(cfg.max_threads)
threads = []
last_request_time = [0]
throttle_lock = threading.Lock()
results = {}
results_lock = threading.Lock()
def worker(symbol: str):
try:
with semaphore:
with throttle_lock:
elapsed = time.time() - last_request_time[0]
if cfg.symbol_delay is not None and elapsed < cfg.symbol_delay:
sleep_time = cfg.symbol_delay - elapsed
logger.debug("[%s] 스로틀 대기: %.2f", symbol, sleep_time)
time.sleep(sleep_time)
last_request_time[0] = time.time()
res = process_symbol(symbol, cfg=cfg)
with results_lock:
results[symbol] = res
except Exception as e:
logger.exception("[%s] 워커 스레드 오류: %s", symbol, e)
for sym in symbols:
t = threading.Thread(target=worker, args=(sym,), name=f"Worker-{sym}")
threads.append(t)
t.start()
for t in threads:
t.join()
alerts = []
buy_signal_count = 0
for sym in symbols:
with results_lock:
res = results.get(sym)
if not res:
logger.warning("심볼 결과 없음: %s", sym)
continue
for line in res.get("summary", []):
logger.info(line)
if res.get("telegram"):
buy_signal_count += 1
if cfg.dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
res["telegram"],
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
alerts.append({"symbol": sym, "text": res["telegram"]})
if aggregate_enabled and len(alerts) > 1:
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
summary_lines += [f"- {a['symbol']}" for a in alerts]
summary_text = "\n".join(summary_lines)
if cfg.dry_run:
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
else:
if cfg.telegram_bot_token and cfg.telegram_chat_id:
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
summary_text,
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
else:
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
if cfg.telegram_bot_token and cfg.telegram_chat_id and not any(a.get("text") for a in alerts):
send_telegram(
cfg.telegram_bot_token,
cfg.telegram_chat_id,
"[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)",
add_thread_prefix=False,
parse_mode=cfg.telegram_parse_mode,
)
logger.info("병렬 처리 완료")
return buy_signal_count

34
test_main_short.py Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python
"""짧은 시간 실행 후 자동 종료하는 테스트 스크립트"""
import os
import sys
import signal
import threading
from dotenv import load_dotenv
load_dotenv()
# 5초 후 자동 종료 타이머
def auto_exit():
import time
time.sleep(5)
print("\n[자동 종료] 5초 경과, 프로그램 종료")
os._exit(0)
# 타이머 시작
timer = threading.Thread(target=auto_exit, daemon=True)
timer.start()
# main.py 실행
if __name__ == "__main__":
from main import main
try:
main()
except KeyboardInterrupt:
print("\n[종료] 사용자 중단")
except SystemExit:
pass

41
test_run.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python
"""간단한 실행 테스트 스크립트"""
import os
import sys
from dotenv import load_dotenv
load_dotenv()
# 테스트용 환경변수 설정
os.environ["DRY_RUN"] = "true"
from src.config import load_config, build_runtime_config
from src.signals import process_symbol
def test_process_symbol():
"""process_symbol 함수 호출 테스트"""
config = load_config()
cfg = build_runtime_config(config)
# 테스트 심볼
test_symbol = "KRW-BTC"
print(f"[테스트] {test_symbol} 처리 시작...")
try:
# 실제 호출 형태 (threading_utils에서 사용하는 방식)
result = process_symbol(test_symbol, cfg=cfg)
print(f"[성공] 결과: {result.get('symbol')} - 오류: {result.get('error')}")
print(f"[요약] {result.get('summary', [])[:3]}")
return True
except Exception as e:
print(f"[실패] 오류 발생: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = test_process_symbol()
sys.exit(0 if success else 1)