최초 프로젝트 업로드 (Script Auto Commit)
This commit is contained in:
113
.github/copilot-instructions.md
vendored
Normal file
113
.github/copilot-instructions.md
vendored
Normal 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
58
.gitignore
vendored
Normal 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
22
.pre-commit-config.yaml
Normal 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
37
Dockerfile
Normal 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
162
README.md
Normal 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
54
config/config.json
Normal 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
105
config/symbols.txt
Normal 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
1
data/holdings.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
101
docs/IMPROVEMENTS_REPORT.md
Normal file
101
docs/IMPROVEMENTS_REPORT.md
Normal 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
|
||||
35
docs/implementation_plan.md
Normal file
35
docs/implementation_plan.md
Normal 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)
|
||||
45
docs/project_requirements.md
Normal file
45
docs/project_requirements.md
Normal 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
166
docs/project_state.md
Normal 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
108
docs/review_prompt.md
Normal 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
132
docs/user_guide.md
Normal 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
70
docs/workflow.md
Normal 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
91
docs/workflow_manual.md
Normal 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
91
git_init.bat.bat
Normal 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
366
main.py
Normal 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
48
pyproject.toml
Normal 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
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = src/tests
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal 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
4
src/__init__.py
Normal 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
111
src/common.py
Normal 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
259
src/config.py
Normal 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
333
src/holdings.py
Normal 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
167
src/indicators.py
Normal 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
108
src/notifications.py
Normal 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
759
src/order.py
Normal 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
68
src/retry_utils.py
Normal 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
843
src/signals.py
Normal 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
1
src/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
111
src/tests/test_boundary_conditions.py
Normal file
111
src/tests/test_boundary_conditions.py
Normal 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"])
|
||||
118
src/tests/test_critical_fixes.py
Normal file
118
src/tests/test_critical_fixes.py
Normal 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"])
|
||||
143
src/tests/test_evaluate_sell_conditions.py
Normal file
143
src/tests/test_evaluate_sell_conditions.py
Normal 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
74
src/tests/test_helpers.py
Normal 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
93
src/tests/test_main.py
Normal 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
160
src/threading_utils.py
Normal 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
34
test_main_short.py
Normal 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
41
test_run.py
Normal 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)
|
||||
Reference in New Issue
Block a user