From dd9acf62a3c17c4da3599a35323c01caf0690ae7 Mon Sep 17 00:00:00 2001 From: tae2564 Date: Wed, 3 Dec 2025 22:40:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B5=9C=EC=B4=88=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=97=85=EB=A1=9C=EB=93=9C=20(Script=20Au?= =?UTF-8?q?to=20Commit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/copilot-instructions.md | 113 +++ .gitignore | 58 ++ .pre-commit-config.yaml | 22 + Dockerfile | 37 + README.md | 162 ++++ config/config.json | 54 ++ config/symbols.txt | 105 +++ data/holdings.json | 1 + docs/IMPROVEMENTS_REPORT.md | 101 +++ docs/implementation_plan.md | 35 + docs/project_requirements.md | 45 ++ docs/project_state.md | 166 ++++ docs/review_prompt.md | 108 +++ docs/user_guide.md | 132 ++++ docs/workflow.md | 70 ++ docs/workflow_manual.md | 91 +++ git_init.bat.bat | 91 +++ main.py | 366 +++++++++ pyproject.toml | 48 ++ pytest.ini | 2 + requirements.txt | 17 + src/__init__.py | 4 + src/common.py | 111 +++ src/config.py | 259 +++++++ src/holdings.py | 333 ++++++++ src/indicators.py | 167 ++++ src/notifications.py | 108 +++ src/order.py | 759 +++++++++++++++++++ src/retry_utils.py | 68 ++ src/signals.py | 843 +++++++++++++++++++++ src/tests/__init__.py | 1 + src/tests/test_boundary_conditions.py | 111 +++ src/tests/test_critical_fixes.py | 118 +++ src/tests/test_evaluate_sell_conditions.py | 143 ++++ src/tests/test_helpers.py | 74 ++ src/tests/test_main.py | 93 +++ src/threading_utils.py | 160 ++++ test_main_short.py | 34 + test_run.py | 41 + 39 files changed, 5251 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/symbols.txt create mode 100644 data/holdings.json create mode 100644 docs/IMPROVEMENTS_REPORT.md create mode 100644 docs/implementation_plan.md create mode 100644 docs/project_requirements.md create mode 100644 docs/project_state.md create mode 100644 docs/review_prompt.md create mode 100644 docs/user_guide.md create mode 100644 docs/workflow.md create mode 100644 docs/workflow_manual.md create mode 100644 git_init.bat.bat create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/common.py create mode 100644 src/config.py create mode 100644 src/holdings.py create mode 100644 src/indicators.py create mode 100644 src/notifications.py create mode 100644 src/order.py create mode 100644 src/retry_utils.py create mode 100644 src/signals.py create mode 100644 src/tests/__init__.py create mode 100644 src/tests/test_boundary_conditions.py create mode 100644 src/tests/test_critical_fixes.py create mode 100644 src/tests/test_evaluate_sell_conditions.py create mode 100644 src/tests/test_helpers.py create mode 100644 src/tests/test_main.py create mode 100644 src/threading_utils.py create mode 100644 test_main_short.py create mode 100644 test_run.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..17a766f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,113 @@ + + +# 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가 알아야 할 중요 설계 결정, 변수명, 남은 이슈 등 기술적 메모. + + + +추가 질문이 있는 경우 문의하세요. + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bc112 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f580852 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a28e1ab --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ff2043 --- /dev/null +++ b/README.md @@ -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 + ``` + +--- + +이 문서는 프로젝트의 최신 구조와 실행 방법을 반영하도록 업데이트되었습니다. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..35d5467 --- /dev/null +++ b/config/config.json @@ -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 + } +} diff --git a/config/symbols.txt b/config/symbols.txt new file mode 100644 index 0000000..f5b276d --- /dev/null +++ b/config/symbols.txt @@ -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 \ No newline at end of file diff --git a/data/holdings.json b/data/holdings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/data/holdings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docs/IMPROVEMENTS_REPORT.md b/docs/IMPROVEMENTS_REPORT.md new file mode 100644 index 0000000..d77ad07 --- /dev/null +++ b/docs/IMPROVEMENTS_REPORT.md @@ -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 diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md new file mode 100644 index 0000000..a085408 --- /dev/null +++ b/docs/implementation_plan.md @@ -0,0 +1,35 @@ + + +# 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) diff --git a/docs/project_requirements.md b/docs/project_requirements.md new file mode 100644 index 0000000..84580b0 --- /dev/null +++ b/docs/project_requirements.md @@ -0,0 +1,45 @@ + + +# 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) 로직 구현] diff --git a/docs/project_state.md b/docs/project_state.md new file mode 100644 index 0000000..6963947 --- /dev/null +++ b/docs/project_state.md @@ -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, 백테스트 모듈 착수 diff --git a/docs/review_prompt.md b/docs/review_prompt.md new file mode 100644 index 0000000..0dd82b2 --- /dev/null +++ b/docs/review_prompt.md @@ -0,0 +1,108 @@ + + + + + + + + +# 코드 리뷰 프롬프트 +다음 지침에 따라 코드를 검토하고 코드 리뷰 레포트(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가지만 간결하게) diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 0000000..2f37293 --- /dev/null +++ b/docs/user_guide.md @@ -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.`) 후 재생성됨 | + +## 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.` 백업 후 새로 시작. 백업 파일 검토 가능. + +**Q: 설정 변경 반영 시점?** +A: 프로세스 재시작 필요. 장기 실행 중에는 실시간 반영 안 함. + +**Q: 최소 주문 금액 미만 상황?** +A: 매도/매수 모두 안전하게 건너뛰고 로그와 텔레그램 알림(선택)으로 통지. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..03b211c --- /dev/null +++ b/docs/workflow.md @@ -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 +- 작업 중 에러가 발생하거나 정보가 부족하면 즉시 중단하고 사용자에게 구체적인 질문을 하십시오. +- 사용자가 "중단" 또는 "수정"을 요청하면 즉시 루프를 멈추고 지시를 따르십시오. diff --git a/docs/workflow_manual.md b/docs/workflow_manual.md new file mode 100644 index 0000000..93cd924 --- /dev/null +++ b/docs/workflow_manual.md @@ -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 파일에 작성해줘. diff --git a/git_init.bat.bat b/git_init.bat.bat new file mode 100644 index 0000000..1d412e1 --- /dev/null +++ b/git_init.bat.bat @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0422522 --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1fce33 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d930cd5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = src/tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8d0be4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Core dependencies +pyupbit +pandas +pandas_ta +requests +python-dotenv + +# Testing +pytest + +# Code quality +black +ruff +pre-commit + +# Utilities +chardet diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..bc4f621 --- /dev/null +++ b/src/__init__.py @@ -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 diff --git a/src/common.py b/src/common.py new file mode 100644 index 0000000..35fbf44 --- /dev/null +++ b/src/common.py @@ -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, + ) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..89d6014 --- /dev/null +++ b/src/config.py @@ -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") diff --git a/src/holdings.py b/src/holdings.py new file mode 100644 index 0000000..b8be611 --- /dev/null +++ b/src/holdings.py @@ -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 diff --git a/src/indicators.py b/src/indicators.py new file mode 100644 index 0000000..0f9a788 --- /dev/null +++ b/src/indicators.py @@ -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 # 예외를 호출자에게 전파하여 명시적 처리 강제 diff --git a/src/notifications.py b/src/notifications.py new file mode 100644 index 0000000..e61056e --- /dev/null +++ b/src/notifications.py @@ -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가 설정되어 있지 않습니다") diff --git a/src/order.py b/src/order.py new file mode 100644 index 0000000..fcd29ff --- /dev/null +++ b/src/order.py @@ -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"[확인필요] 자동매도 주문 대기\n" + msg += f"토큰: {token}\n" + msg += f"심볼: {symbol}\n" + msg += f"매도수량: {amount:.8f}\n\n" + msg += f"확인 방법: 파일 생성 -> confirm_{token}\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"[확인필요] 자동매수 주문 대기\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}초" + 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(), + } diff --git a/src/retry_utils.py b/src/retry_utils.py new file mode 100644 index 0000000..eb77f80 --- /dev/null +++ b/src/retry_utils.py @@ -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 diff --git a/src/signals.py b/src/signals.py new file mode 100644 index 0000000..1d96c3f --- /dev/null +++ b/src/signals.py @@ -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"🔴 매도 신호: {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'시장: Upbit {symbol}' + 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 diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..66173ae --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1 @@ +# Test package diff --git a/src/tests/test_boundary_conditions.py b/src/tests/test_boundary_conditions.py new file mode 100644 index 0000000..d0d7834 --- /dev/null +++ b/src/tests/test_boundary_conditions.py @@ -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"]) diff --git a/src/tests/test_critical_fixes.py b/src/tests/test_critical_fixes.py new file mode 100644 index 0000000..47e2d8a --- /dev/null +++ b/src/tests/test_critical_fixes.py @@ -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"]) diff --git a/src/tests/test_evaluate_sell_conditions.py b/src/tests/test_evaluate_sell_conditions.py new file mode 100644 index 0000000..451e71f --- /dev/null +++ b/src/tests/test_evaluate_sell_conditions.py @@ -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 diff --git a/src/tests/test_helpers.py b/src/tests/test_helpers.py new file mode 100644 index 0000000..9c46ddc --- /dev/null +++ b/src/tests/test_helpers.py @@ -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) diff --git a/src/tests/test_main.py b/src/tests/test_main.py new file mode 100644 index 0000000..9c279da --- /dev/null +++ b/src/tests/test_main.py @@ -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 diff --git a/src/threading_utils.py b/src/threading_utils.py new file mode 100644 index 0000000..d8e30ae --- /dev/null +++ b/src/threading_utils.py @@ -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 diff --git a/test_main_short.py b/test_main_short.py new file mode 100644 index 0000000..345db35 --- /dev/null +++ b/test_main_short.py @@ -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 diff --git a/test_run.py b/test_run.py new file mode 100644 index 0000000..765f087 --- /dev/null +++ b/test_run.py @@ -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)