From ed7084dd8f0faf74386b622b3bd207700916e5ef Mon Sep 17 00:00:00 2001 From: tae2564 Date: Wed, 3 Dec 2025 22:37:46 +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 | 43 ++ .gitignore | 58 +++ Dockerfile | 35 ++ README.md | 120 +++++ config/config.json | 54 ++ config/symbols.txt | 4 + docs/DEPLOYMENT.md | 354 +++++++++++++ docs/PRD.md | 33 ++ docs/implementation_plan.md | 30 ++ docs/review_prompt.md | 88 ++++ docs/sample.md | 4 + git_init.bat.bat | 91 ++++ holdings.json | 1 + main.py | 268 ++++++++++ pytest.ini | 2 + requirements.txt | 7 + src/__init__.py | 4 + src/common.py | 26 + src/config.py | 94 ++++ src/holdings.py | 245 +++++++++ src/indicators.py | 192 +++++++ src/kiwoom_api.py | 223 +++++++++ src/notifications.py | 67 +++ src/order.py | 317 ++++++++++++ src/signals.py | 551 +++++++++++++++++++++ src/tests/__init__.py | 1 + src/tests/test_evaluate_sell_conditions.py | 81 +++ src/tests/test_helpers.py | 56 +++ src/tests/test_main.py | 67 +++ src/threading_utils.py | 138 ++++++ 30 files changed, 3254 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/symbols.txt create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/PRD.md create mode 100644 docs/implementation_plan.md create mode 100644 docs/review_prompt.md create mode 100644 docs/sample.md create mode 100644 git_init.bat.bat create mode 100644 holdings.json create mode 100644 main.py 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/kiwoom_api.py create mode 100644 src/notifications.py create mode 100644 src/order.py create mode 100644 src/signals.py create mode 100644 src/tests/__init__.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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..805c4d5 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + +# Project Rules & AI Persona + +## 1. Role & Persona +- 당신은 Google, Meta 출신의 20년 차 **Principal Software Engineer**입니다. +- **C++ (C++17/20)** 및 **Python (3.11+)** 전문가입니다. +- 코드는 간결하고, 성능 효율적이며, 유지보수 가능해야 합니다. +- 불필요한 서론(대화)을 생략하고 **코드와 핵심 설명**만 출력하십시오. + +## 2. Tech Stack & Style +- **Python:** + - 모든 함수에 Type Hinting (typing 모듈) 필수 적용. + - PEP8 스타일 준수 (Black Formatter 기준). + - Docstring은 Google Style을 따름. +- **C++:** + - Modern C++ (Smart Pointers, RAII, move semantics) 적극 활용. + - Raw pointer 사용 금지 (필수적인 경우 주석으로 사유 명시). + - Google C++ Style Guide 준수. + +## 3. Coding Principles +- **DRY (Don't Repeat Yourself):** 중복 로직은 반드시 함수나 클래스로 분리. +- **Early Return:** 들여쓰기 깊이(Indentation depth)를 최소화하기 위해 가드 절(Guard Clause) 사용. +- **Error Handling:** + - `try-except` (Python) 또는 `try-catch` (C++)를 남용하지 말 것. + - 예외를 삼키지 말고(Silent Failure 금지), 명확한 에러 로그를 남길 것. +- **Security:** API Key, 비밀번호 등 민감 정보는 절대 하드코딩 금지 (.env 사용). + +## 4. Response Rules +- 코드를 수정할 때는 변경된 부분만 보여주지 말고, **문맥 파악이 가능한 전체 함수/블록**을 보여주세요. +- 파일 경로와 이름을 코드 블록 상단에 항상 주석으로 명시하세요. \ 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b419167 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Synology DSM용 MACD 알림 봇 Dockerfile +FROM python:3.12-slim + +# 필수 패키지 설치 +RUN apt-get update && apt-get install -y \ + build-essential \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# 작업 디렉토리 생성 및 이동 +WORKDIR /app + +# 소스 복사 +COPY . /app + +# 파이썬 패키지 설치 +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# 환경변수 예시 파일 복사 (필요시) +# COPY .env.example .env + +# 로그 폴더 생성 +RUN mkdir -p logs + +# 기본 실행 명령 +CMD ["python", "main.py"] + +# 포트 노출 (필요시) +# EXPOSE 8000 + +# 시놀로지 DSM에서 빌드 예시: +# docker build -t macd_alarm . +# docker run --env-file .env -v $(pwd)/logs:/app/logs macd_alarm diff --git a/README.md b/README.md new file mode 100644 index 0000000..8dac2ac --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# 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, candle_count +# - telegram_bot_token, telegram_chat_id +# - upbit_access_key, upbit_secret_key +# - dry_run, max_threads, trading_mode 등 +``` + +--- + +## 실행 방법 + +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..ae6c6ca --- /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, + "loop": true, + "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": 1000000, + "min_order_value": 100000, + "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, + "partial_sell_ratio": 0.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 + }, + "kiwoom": { + "account_number": "YOUR_ACCOUNT_NUMBER" + } +} \ No newline at end of file diff --git a/config/symbols.txt b/config/symbols.txt new file mode 100644 index 0000000..39706e9 --- /dev/null +++ b/config/symbols.txt @@ -0,0 +1,4 @@ +# symbols.txt - 한 줄에 하나의 주식 종목코드 입력 +# 빈 줄과 #으로 시작하는 줄은 무시됨 +005930 +000660 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..b0d8fcf --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,354 @@ +# 자동매매 활성화 가이드 (Deployment & Safety) + +## 개요 +이 문서는 MACD 알림 봇의 **자동매매 기능(auto_trade)**을 안전하게 활성화하고 운영하는 방법을 설명합니다. + +--- + +## 1. 사전 준비 (필수) + +### 1.1 Upbit API 키 생성 +1. [Upbit 홈페이지](https://upbit.com) 로그인 +2. 계정 설정 → API 관리 → "Open API" 클릭 +3. "오픈 API 키 생성" 버튼 클릭 +4. 접근 권한: + - ✅ **조회**: 계좌, 자산, 주문내역 (필수) + - ✅ **매매**: 매도 주문 (필수) + - ✅ **출금**: 해제 권장 (자동매매에 불필요) +5. IP 화이트리스트: 봇이 실행되는 서버 IP 추가 (선택, 권장) +6. Access Key / Secret Key 메모 (비밀 유지!) + +### 1.2 Telegram Bot 토큰 & Chat ID 확인 +- 기존에 설정했다면 그대로 사용 가능 +- 없다면 [BotFather로 새 봇 생성](https://core.telegram.org/bots#botfather) 후 Chat ID 확인 + +### 1.3 환경변수 설정 (`.env` 파일) +```bash +# Telegram 알림 +TELEGRAM_BOT_TOKEN=your_bot_token +TELEGRAM_CHAT_ID=your_chat_id + +# Upbit API (자동매매 시 필수) +UPBIT_ACCESS_KEY=your_access_key +UPBIT_SECRET_KEY=your_secret_key + +# 선택: 자동매매 활성화 확인 (require_env_confirm=true일 때) +AUTO_TRADE_ENABLED=1 + +# 선택: 주문 모니터링 타임아웃 (초, 기본 120) +ORDER_MONITOR_TIMEOUT=120 + +# 선택: 주문 폴링 간격 (초, 기본 3) +ORDER_POLL_INTERVAL=3 + +# 선택: 재시도 횟수 (기본 1) +ORDER_MAX_RETRIES=1 +``` + +⚠️ `.env` 파일을 `.gitignore`에 추가하여 비밀 정보를 저장소에 커밋하지 마세요. + +--- + +## 2. config.json 설정 + +### 2.1 기본 설정 (신중하게) +```json +{ + "trading_mode": "signal_only", + "auto_trade": { + "enabled": false, + "max_trade_krw": 1000000, + "allowed_symbols": [], + "require_env_confirm": true + }, + "confirm": { + "confirm_via_file": true, + "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 + }, + "dry_run": true +} +``` + +### 2.2 각 항목 설명 + +#### `trading_mode` +- `"signal_only"` (기본): Telegram 알림만 전송 +- `"auto_trade"`: 자동매매 시도 (수동 확인 필수) +- `"mixed"`: 알림 + 거래 기록 + +#### `auto_trade` +- `enabled`: `false`로 유지 (기본값). 실제 자동매도 활성화하려면 명시적으로 `true`로 변경 +- `buy_enabled`: `false`로 유지 (기본값). 실제 자동매수 활성화하려면 명시적으로 `true`로 변경 +- `buy_amount_krw`: 매수 시 사용할 KRW 금액 (예: 10,000) +- `max_trade_krw`: 한 번에 매도할 최대 KRW 금액 (예: 1,000,000) +- `allowed_symbols`: 자동매매 허용 심볼 목록. 빈 배열이면 모든 심볼 허용 + - 예: `["KRW-BTC", "KRW-ETH"]` (이 심볼들만 자동매매) +- `require_env_confirm`: `true`이면 환경변수 `AUTO_TRADE_ENABLED=1` 필요 (권장) + +#### `confirm` +- `confirm_via_file`: 파일 기반 확인 활성화 여부 +- `confirm_timeout`: 확인 대기 시간(초). 이 시간 내에 확인이 없으면 주문 취소 + +#### `monitor` +- `enabled`: 주문 모니터링 활성화 +- `timeout`: 주문 폴링 타임아웃(초) +- `poll_interval`: 주문 상태 확인 간격(초) +- `max_retries`: 타임아웃 시 재시도 횟수 + +#### `notify` +- `order_filled`: 완전체결 시 알림 +- `order_partial`: 부분체결/타임아웃 시 알림 +- `order_cancelled`: 주문 취소 시 알림 +- `order_error`: 오류 발생 시 알림 + +--- + +## 3. 안전 체크리스트 (자동매매 활성화 전) + +### ✅ 필수 확인 항목 +- [ ] Upbit API 키가 올바르게 발급되었나? (테스트 계좌에서 실제 키 사용하기) +- [ ] `.env` 파일이 `.gitignore`에 있나? +- [ ] `config.json`의 `dry_run`이 `true`로 설정되어 있나? (테스트 단계) +- [ ] `auto_trade.enabled`가 `false`로 설정되어 있나? (테스트 단계) +- [ ] 로그 파일(`logs/macd_alarm.log`)을 확인할 수 있는 환경인가? + +### ✅ 단계적 활성화 (점진적 위험 증가) + +#### 3.1 단계 1: Signal-Only 모드 (알림만) +```json +{ + "trading_mode": "signal_only", + "dry_run": true +} +``` +- 실행: `python main.py` +- 기대 결과: 매도/매수 신호 감지 시 Telegram 알림 수신 +- 확인: 로그에 오류 없고, Telegram 메시지가 제대로 도착하는가? + +#### 3.2 단계 2: Mixed 모드 (알림 + 기록, dry-run) +```json +{ + "trading_mode": "mixed", + "auto_trade": { + "enabled": false, + "buy_enabled": false + }, + "dry_run": true +} +``` +- 실행: `python main.py` +- 기대 결과: 매도 신호 시 Telegram + `trades.json` 기록 +- 매수 신호는 `auto_trade.buy_enabled`가 true일 때만 `trades.json`에 기록됨 +- 확인: `trades.json`에 매도 기록이 제대로 저장되는가? (매수 기록은 buy_enabled가 true일 때만 저장됨) + +#### 3.3 단계 3: Auto-Trade 시뮬레이션 (dry-run, 실제 주문 아님) +```json +{ + "trading_mode": "auto_trade", + "auto_trade": { + "enabled": true, + "max_trade_krw": 100000, + "allowed_symbols": [], + "require_env_confirm": true + }, + "dry_run": true +} +``` +- 환경변수 설정: + ```bash + $env:AUTO_TRADE_ENABLED = "1" + ``` +- 실행: `python main.py` +- 기대 결과: + - 매도 신호 감지 시 Telegram으로 확인 토큰 수신 + - 토큰으로 파일 생성 후 주문 진행 (시뮬레이션) + - `pending_orders.json` 및 `trades.json`에 기록 +- 확인: + - Telegram 확인 메시지 형식이 명확한가? + - 파일 생성으로 확인 메커니즘이 동작하는가? + - 거래 기록이 제대로 저장되는가? + +#### 3.4 단계 4: **실제 자동매도 활성화** (최종 단계, 신중!) +```json +{ + "trading_mode": "auto_trade", + "auto_trade": { + "enabled": true, + "buy_enabled": false, + "max_trade_krw": 100000, + "allowed_symbols": ["KRW-BTC"], + "require_env_confirm": true + }, + "dry_run": false +} +``` +- 환경변수 설정: + ```bash + $env:AUTO_TRADE_ENABLED = "1" + ``` +- Upbit 테스트 계좌 또는 소액 계좌 사용 권장 +- 실행: `python main.py` +- 확인: + - Telegram에서 실제 매도 주문 확인 메시지 수신 + - 파일 생성으로 주문 실행 + - Upbit 계정에서 주문 이력 확인 + - 모니터링 후 체결 알림 수신 + +#### 3.5 단계 5: **실제 자동매수 활성화** (최종 단계, 매우 신중!) +```json +{ + "trading_mode": "auto_trade", + "auto_trade": { + "enabled": true, + "buy_enabled": true, + "buy_amount_krw": 10000, + "max_trade_krw": 100000, + "allowed_symbols": ["KRW-BTC"], + "require_env_confirm": true + }, + "dry_run": false +} +``` +- 환경변수 설정: + ```bash + $env:AUTO_TRADE_ENABLED = "1" + ``` +- ⚠️ **중요**: 자동매수는 자본 손실 위험이 높습니다. 반드시 소액으로 시작하세요. +- Upbit 테스트 계좌 또는 소액 계좌 사용 필수 +- 실행: `python main.py` +- 확인: + - 매수 신호 감지 시 Telegram에서 매수 주문 확인 메시지 수신 + - 파일 생성으로 주문 실행 + - Upbit 계정에서 매수 주문 이력 확인 + - 모니터링 후 체결 알림 수신 + - `holdings.json`에 새로운 보유 정보 자동 기록 + +--- + +## 4. 주문 확인 프로세스 + +### 4.1 Telegram 기반 확인 (현재 구현) +1. 자동매매 신호 감지 → Telegram 알림 수신 + ``` + [확인필요] 자동매매 주문 대기 + 토큰: a1b2c3d4 + 심볼: KRW-BTC + 주문량: 0.00010000 + ``` + +2. 프로젝트 루트에서 파일 생성 (PowerShell 예): + ```powershell + New-Item "confirm_a1b2c3d4" -ItemType File + ``` + 또는 `confirmed_tokens.txt` 파일에 토큰 추가: + ``` + a1b2c3d4 + ``` + +3. 코드가 자동으로 파일/토큰 감지 → 주문 실행 + +### 4.2 타임아웃 +- 기본 300초(5분) 내에 확인이 없으면 자동 취소 +- `config.json`의 `confirm.confirm_timeout`으로 조정 가능 + +--- + +## 5. 운영 (Troubleshooting) + +### 주문이 실행되지 않는 경우 +1. **환경변수 확인**: + ```bash + echo $env:AUTO_TRADE_ENABLED + # 결과: 1이어야 함 + ``` + +2. **API 키 확인**: + ```bash + echo $env:UPBIT_ACCESS_KEY + # 결과: 실제 키가 설정되어 있어야 함 + ``` + +3. **로그 파일 확인**: + ```bash + Get-Content "logs/macd_alarm.log" -Tail 20 + # "자동매매 비활성화" 또는 "Upbit API 키 없음" 오류 찾기 + ``` + +4. **config.json 문법 확인**: + ```bash + python -c "import json; json.load(open('config.json'))" + # JSON 파싱 오류 있으면 출력됨 + ``` + +### 부분체결 처리 +- 주문량이 크거나 시장 유동성이 낮으면 부분체결 발생 가능 +- `ORDER_MAX_RETRIES` 환경변수로 재시도 횟수 조정 (기본 1회) +- 모니터링은 `ORDER_MONITOR_TIMEOUT` 시간(기본 120초) 동안 진행 + +### 긴급 중지 +- 파일 생성하지 않으면 `confirm_timeout` 후 자동 취소 +- 또는 `dry_run: true`로 되돌려서 봇 재시작 + +--- + +## 6. 성능/안정성 팁 + +1. **작은 규모로 시작**: + - `max_trade_krw`: 처음엔 10만~100만 KRW 범위 + - `allowed_symbols`: 확실한 심볼 몇 개만 선택 + +2. **로그 모니터링**: + - 실시간: `Get-Content "logs/macd_alarm.log" -Tail 10 -Wait` + - 또는 별도 터미널에서: `tail -f logs/macd_alarm.log` + +3. **야간/주말 고려**: + - Upbit는 24/7 운영이지만, 유동성이 낮은 시간대 존재 + - 백테스트 후 적절한 시간대에만 매매 권장 + +4. **백업**: + - `trades.json` 주기적 백업 + - 또는 SQLite로 마이그레이션 고려 + +--- + +## 7. FAQ + +**Q: 실제로 주문이 체결되면 어떻게 되나?** +A: Upbit 계정에서 직접 체결되며, `trades.json` + Telegram 알림으로 기록됩니다. + +**Q: 파일 생성을 깜빡하면?** +A: `confirm_timeout` 후 자동 취소되고, `trades.json`에 `user_not_confirmed` 기록됩니다. + +**Q: 여러 심볼이 동시에 신호 나면?** +A: 각 심볼마다 독립적인 토큰으로 확인 요청. 병렬 처리 가능. + +**Q: 테스트 중 실제 주문을 방지하려면?** +A: `dry_run: true` 또는 `auto_trade.enabled: false`, `auto_trade.buy_enabled: false` 유지. + +**Q: 자동매수와 자동매도를 독립적으로 제어할 수 있나?** +A: 네. `auto_trade.enabled`는 매도만, `auto_trade.buy_enabled`는 매수만 제어합니다. 각각 독립적으로 활성화/비활성화 가능합니다. + +**Q: 매수 후 holdings.json은 자동으로 업데이트되나?** +A: 네. 매수 체결 완료 시 자동으로 `holdings.json`에 매수가, 수량, 최고가, 매수시간이 기록됩니다. + +**Q: 자동매수 금액을 어떻게 설정하나?** +A: `config.json`의 `auto_trade.buy_amount_krw`에 매수할 KRW 금액을 설정하세요. (예: 10000 = 1만원) + +--- + +## 8. 법적 공지 + +⚠️ **면책**: 이 봇은 교육 목적으로 제작되었습니다. 실제 투자/자동매매 시 발생하는 손실에 대해 개발자는 책임을 지지 않습니다. 충분한 테스트 후 자신의 책임 하에 사용하세요. + diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..b754a94 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,33 @@ + + + +# Product Requirements Document (PRD) + +## 1. Project Overview +- **프로젝트명:** (예: Stock-Finder-AI) +- **목적:** (예: 미국/한국 주식 시장에서 특정 조건에 맞는 종목을 필터링하고, 매수/매도 신호를 포착하여 알림을 보낸다.) +- **주요 사용자:** 퀀트 투자자, 개인 트레이더 + +## 2. Core Features (User Stories) +1. **데이터 수집:** 야후 파이낸스 API 및 한국투자증권 API를 통해 일봉/분봉 데이터를 수집한다. +2. **지표 계산:** 수집된 데이터로 RSI, Bollinger Bands, MACD를 계산한다. +3. **필터링 로직:** PBR < 1.0 이면서 RSI < 30인 종목을 추출한다. +4. **알림 발송:** 추출된 종목을 텔레그램 봇으로 전송한다. + +## 3. Data Flow & Architecture +- **Input:** 종목 리스트 (Ticker List), 설정된 파라미터 (config.json) +- **Process:** + 1. Data Fetcher -> (Raw Data) -> DB 저장 + 2. Indicator Engine -> (Calculated Data) + 3. Screener -> (Filtered List) +- **Output:** JSON 리포트 및 메신저 알림 + +## 4. File Structure Plan +- /src/data_loader.py : API 연동 및 데이터 수집 +- /src/indicators.py : 기술적 지표 계산 로직 +- /src/screener.py : 필터링 및 종목 선정 핵심 로직 +- /src/notifier.py : 메시지 발송 처리 + +## 5. Non-Functional Requirements +- **성능:** 2000개 종목 스캔을 3분 이내 완료할 것 (멀티스레딩/비동기 필수). +- **안정성:** API 호출 제한(Rate Limit) 도달 시 자동으로 Backoff/Retry 수행. \ No newline at end of file diff --git a/docs/implementation_plan.md b/docs/implementation_plan.md new file mode 100644 index 0000000..3002085 --- /dev/null +++ b/docs/implementation_plan.md @@ -0,0 +1,30 @@ + + + +# Implementation Plan + +이 문서는 개발 진행 상황을 추적합니다. AI는 이 문서를 참조하여 현재 단계의 작업을 수행해야 합니다. + +## Phase 1: 환경 설정 및 기본 구조 [ ] +- [ ] 프로젝트 폴더 구조 생성 및 Git 초기화 +- [ ] `copilot-instructions.md` 및 `.env` 템플릿 작성 +- [ ] Python 가상환경 설정 및 `requirements.txt` 작성 (pandas, numpy, requests 등) + +## Phase 2: 데이터 수집 모듈 (Data Fetcher) [ ] +- [ ] `data_loader.py`: 외부 API 연동 클래스 작성 +- [ ] API Rate Limit 처리 로직 (Retry/Backoff) 구현 +- [ ] 단위 테스트: API 연결 및 데이터 수신 확인 + +## Phase 3: 핵심 로직 구현 (Core Logic) [ ] +- [ ] `indicators.py`: RSI, MACD 계산 함수 구현 +- [ ] `screener.py`: 조건식 필터링 엔진 구현 +- [ ] 단위 테스트: 샘플 데이터를 이용한 지표 계산 정확도 검증 + +## Phase 4: 알림 및 메인 실행 (Interface) [ ] +- [ ] `notifier.py`: 텔레그램/슬랙 메시지 발송 함수 +- [ ] `main.py`: 전체 프로세스(수집->계산->필터->알림) 통합 실행 +- [ ] 통합 테스트 (Integration Test) + +## Phase 5: 최적화 및 리팩토링 [ ] +- [ ] 비동기(asyncio) 적용으로 속도 개선 +- [ ] 코드 리뷰 프롬프트(`review_prompt.md`) 기반 자가 점검 수행 \ No newline at end of file diff --git a/docs/review_prompt.md b/docs/review_prompt.md new file mode 100644 index 0000000..7f6b392 --- /dev/null +++ b/docs/review_prompt.md @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[1. 분석 컨텍스트] +정확한 분석을 위해 아래 정보를 기반으로 코드를 검토하십시오. +- 언어/프레임워크: (예: Python 3.11, Spring Boot) +- 코드의 목적: (예: 대용량 트래픽 처리, 결제 로직, 데이터 파싱) +- 주요 제약사항: (예: 동시성 처리 필수, 응답속도 중요, 메모리 효율성) + +[2. 역할 및 원칙] +당신은 '무관용 원칙'을 가진 수석 소프트웨어 아키텍트입니다. +- 목표: 칭찬보다는 결함(Bug), 보안 취약점, 성능 병목, 유지보수 저해 요소를 찾아내는 데 집중하십시오. +- 금지: 코드에 없는 내용을 추측하여 지적하지 마십시오(Zero Hallucination). +- 기준: "작동한다"에 만족하지 말고, "견고하고 안전한가"를 기준으로 판단하십시오. + +[3. 사전 단계: 의도 파악] +분석 전, 이 코드가 수행하는 핵심 로직을 3줄 이내로 요약하여, 당신이 코드를 올바르게 이해했는지 먼저 보여주십시오. + +[4. 심층 검토 체크리스트] +다음 항목을 기준으로 코드를 해부하십시오. +1. 논리 및 엣지 케이스 (Logic & Edge Cases) +- 가상 실행: 코드를 한 줄씩 추적하며 변수 상태 변화를 검증했는가? +- 경계값: Null, 빈 값, 음수, 최대값 등 극한의 입력에서 로직이 깨지지 않는가? +- 예외 처리: 에러를 단순히 삼키지 않고(Silent Failure), 적절히 처리하거나 전파하는가? + +2. 보안 및 안정성 (Security & Stability) +- 입력 검증: SQL 인젝션, XSS, 버퍼 오버플로우 취약점이 없는가? +- 정보 노출: 비밀번호, API 키, PII(개인정보)가 하드코딩되거나 로그에 남지 않는가? +- 자원 관리: 파일, DB 연결, 메모리 등이 예외 발생 시에도 확실히 해제되는가? + +3. 동시성 및 성능 (Concurrency & Performance) +- 동기화: (해당 시) 경쟁 상태(Race Condition), 데드락, 스레드 안전성 문제가 없는가? +- 효율성: 불필요한 중첩 반복문(O(n²)), N+1 쿼리, 중복 연산이 없는가? + +[5. 출력 형식: 결함 보고서] +발견된 문제가 없다면 "특이사항 없음"으로 명시하십시오. 문제가 있다면 아래 양식을 엄수해 주세요. + +🚨 치명적 문제 (Critical Issues) +(서비스 중단, 데이터 손실/오염, 보안 사고 위험이 있는 경우) + +[C-1] 문제 제목 +├─ 위치: [파일경로:라인] 또는 [코드 스니펫 3~5줄] +├─ 원인: [기술적 원인 설명] +├─ 재현/조건: [문제가 발생하는 상황] +└─ 해결책: [수정된 코드 블록 (Auto-Fix)] + +⚠️ 개선 제안 (Warnings & Improvements) +(성능 저하, 유지보수성 부족, 잠재적 버그) + +[W-1] 문제 제목 +├─ 위치: [파일경로:라인] 또는 [코드 스니펫] +├─ 분석: [문제점 설명] +└─ 권장 조치: [리팩토링 제안] + +✅ 잘된 점 (Strengths) +(핵심적인 장점 1~2가지만 간결하게) diff --git a/docs/sample.md b/docs/sample.md new file mode 100644 index 0000000..63a730a --- /dev/null +++ b/docs/sample.md @@ -0,0 +1,4 @@ + + + + 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/holdings.json b/holdings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/holdings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..22b8f39 --- /dev/null +++ b/main.py @@ -0,0 +1,268 @@ +import os +import time +import threading +import argparse +from typing import List, Dict, Tuple +from dotenv import load_dotenv +import logging + +# Modular imports +from src.common import logger, setup_logger +from src.config import load_config, read_symbols, get_symbols_file, build_runtime_config, RuntimeConfig +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 +from src.signals import check_sell_conditions, CheckType +from src.kiwoom_api import get_kiwoom_api + +load_dotenv() + +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 process_symbols_and_holdings( + cfg: RuntimeConfig, + symbols: List[str], + config: Dict[str, any], + last_buy_check_time: float, + last_sell_check_time: float, + last_profit_taking_check_time: float +) -> Tuple[float, float, float]: + """매수/매도 조건을 확인하고 실행. + + Args: + cfg: 런타임 설정 + symbols: 심볼 리스트 + config: 설정 딕셔너리 + last_buy_check_time: 마지막 매수 체크 시간 + last_sell_check_time: 마지막 손절 체크 시간 + last_profit_taking_check_time: 마지막 익절 체크 시간 + + Returns: + (업데이트된 매수 체크 시간, 손절 체크 시간, 익절 체크 시간) + """ + holdings = load_holdings('holdings.json') + 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 + sell_signal_count = 0 + + buy_interval_minutes = config.get("buy_check_interval_minutes", 240) + buy_interval = buy_interval_minutes * 60 + buy_timeframe = minutes_to_timeframe(buy_interval_minutes) + if current_time - last_buy_check_time >= buy_interval: + logger.info("매수 조건 확인 시작 (주기: %d분, 데이터: %s)", buy_interval_minutes, buy_timeframe) + from src.holdings import get_kiwoom_balances + + krw_balance = None + try: + balances = get_kiwoom_balances(cfg.kiwoom_account_number) + krw_balance = balances.get('KRW', 0) + except Exception as e: + logger.warning("Kiwoom 잔고 조회 실패: %s", e) + + buy_amount = config.get('auto_trade', {}).get('buy_amount', 1000000) + if krw_balance is not None and krw_balance < buy_amount: + msg = f"[매수 건너뜀] Kiwoom 계좌 잔고 부족: 현재 KRW={krw_balance:.0f}, 필요={buy_amount:.0f}" + logger.warning(msg) + if cfg.telegram_bot_token and cfg.telegram_chat_id: + send_telegram(cfg.telegram_bot_token, cfg.telegram_chat_id, msg, parse_mode=cfg.telegram_parse_mode or "HTML") + else: + 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, + kiwoom_account_number=cfg.kiwoom_account_number, + 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(buy_candidate_symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=cfg.aggregate_alerts, cfg=cfg_with_buy_timeframe) + else: + buy_signal_count = run_sequential(buy_candidate_symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=cfg.aggregate_alerts, cfg=cfg_with_buy_timeframe) + last_buy_check_time = current_time + else: + logger.debug("매수 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (buy_interval - (current_time - last_buy_check_time)) / 60) + + # 손절/익절 조건 체크 설정 + stop_loss_interval_minutes = config.get("stop_loss_check_interval_minutes", 60) + stop_loss_interval = stop_loss_interval_minutes * 60 + + profit_taking_interval_minutes = config.get("profit_taking_check_interval_minutes", 240) + profit_taking_interval = profit_taking_interval_minutes * 60 + + sell_signal_count = 0 + + # 손절/익절 체크 여부 확인 + should_check_stop_loss = (current_time - last_sell_check_time >= stop_loss_interval) + should_check_profit_taking = (current_time - last_profit_taking_check_time >= profit_taking_interval) + + # Holdings 업데이트는 한 번만 수행 (손절/익절 체크 전) + if should_check_stop_loss or should_check_profit_taking: + account_number = cfg.kiwoom_account_number + + # Holdings 한 번만 업데이트 + if account_number: + try: + from src.holdings import save_holdings, fetch_holdings_from_kiwoom + updated_holdings = fetch_holdings_from_kiwoom(account_number) + if updated_holdings is not None: + holdings = updated_holdings + save_holdings(holdings, 'holdings.json') + except Exception as e: + logger.exception("Kiwoom holdings 조회 중 오류: %s", e) + report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] Holdings 조회 실패: {e}", cfg.dry_run) + + # 손절 체크 (1시간 주기): 조건1, 조건3, 조건4-2, 조건5-2 + if should_check_stop_loss: + logger.info("손절 조건 확인 시작 (주기: %d분)", stop_loss_interval_minutes) + try: + if holdings: + sell_results, count = check_sell_conditions(holdings, cfg=cfg, config=config, check_type=CheckType.STOP_LOSS) + sell_signal_count += count + logger.info("보유 종목 손절 조건 확인 완료: %d개 검사, %d개 매도 신호", len(sell_results), count) + else: + logger.debug("보유 종목 없음") + except Exception as e: + logger.exception("보유 종목 손절 조건 확인 중 오류: %s", e) + report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 손절 조건 확인 실패: {e}", cfg.dry_run) + last_sell_check_time = current_time + + # 익절 체크 (4시간 주기): 조건2, 조건4-1, 조건5-1 + if should_check_profit_taking: + logger.info("익절 조건 확인 시작 (주기: %d분)", profit_taking_interval_minutes) + try: + if holdings: + sell_results, count = check_sell_conditions(holdings, cfg=cfg, config=config, check_type=CheckType.PROFIT_TAKING) + sell_signal_count += count + logger.info("보유 종목 익절 조건 확인 완료: %d개 검사, %d개 매도 신호", len(sell_results), count) + else: + logger.debug("보유 종목 없음") + except Exception as e: + logger.exception("보유 종목 익절 조건 확인 중 오류: %s", e) + report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 익절 조건 확인 실패: {e}", cfg.dry_run) + last_profit_taking_check_time = current_time + else: + if not should_check_stop_loss: + logger.debug("손절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (stop_loss_interval - (current_time - last_sell_check_time)) / 60) + if not should_check_profit_taking: + logger.debug("익절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (profit_taking_interval - (current_time - last_profit_taking_check_time)) / 60) + + logger.info("[요약] 매수 조건 충족 심볼: %d개, 매도 조건 충족 심볼: %d개", buy_signal_count, sell_signal_count) + return last_buy_check_time, last_sell_check_time, last_profit_taking_check_time + +def execute_benchmark(cfg, symbols): + """Execute benchmark to compare single-thread and multi-thread performance.""" + logger.info("간단 벤치마크 시작: 심볼=%d", len(symbols)) + start = time.time() + run_sequential(symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=False, cfg=cfg) + elapsed_single = time.time() - start + logger.info("순차 처리 소요 시간: %.2f초", elapsed_single) + + start = time.time() + run_with_threads(symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=False, cfg=cfg) + elapsed_parallel = time.time() - start + logger.info("병렬 처리(%d 스레드) 소요 시간: %.2f초", cfg.max_threads, elapsed_parallel) + + if elapsed_parallel < elapsed_single: + reduction = (elapsed_single - elapsed_parallel) / elapsed_single * 100 + logger.info("병렬로 %.1f%% 빨라졌습니다 (권장 스레드=%d).", reduction, cfg.max_threads) + else: + logger.info("병렬이 순차보다 빠르지 않습니다. 네트워크/IO 패턴을 점검하세요.") + +def main(): + parser = argparse.ArgumentParser(description="주식 자동매매 프로그램") + parser.add_argument("--benchmark", action="store_true", help="벤치마크 실행") + args = parser.parse_args() + + config = load_config() + if not config: + logger.error("설정 로드 실패; 종료합니다") + return + + cfg = build_runtime_config(config) + setup_logger(cfg.dry_run) + + logger.info("=" * 80) + logger.info("주식 자동매매 프로그램 시작") + logger.info("=" * 80) + + # Kiwoom API 로그인 + get_kiwoom_api() + + symbols = read_symbols(get_symbols_file(config)) + if not symbols: + logger.error("심볼 로드 실패; 종료합니다") + return + + 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("dry-run이 아닐 때 텔레그램 환경변수 필수: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID") + return + + 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("설정: 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("매수 확인 주기: %d분 (%s봉), 손절 확인 주기: %d분 (%s봉), 익절 확인 주기: %d분 (%s봉)", + buy_check_minutes, minutes_to_timeframe(buy_check_minutes), + stop_loss_check_minutes, minutes_to_timeframe(stop_loss_check_minutes), + profit_taking_check_minutes, minutes_to_timeframe(profit_taking_check_minutes)) + + if args.benchmark: + execute_benchmark(cfg, symbols) + return + + last_buy_check_time = 0 + last_sell_check_time = 0 + last_profit_taking_check_time = 0 + if not cfg.loop: + process_symbols_and_holdings(cfg, symbols, config, last_buy_check_time, last_sell_check_time, last_profit_taking_check_time) + else: + 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) + 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("루프 모드 시작: %d분 간격 (매수확인: %d분마다, 손절확인: %d분마다, 익절확인: %d분마다)", + loop_interval_minutes, buy_check_minutes, stop_loss_check_minutes, profit_taking_check_minutes) + try: + while True: + try: + last_buy_check_time, last_sell_check_time, last_profit_taking_check_time = process_symbols_and_holdings( + cfg, symbols, config, last_buy_check_time, last_sell_check_time, last_profit_taking_check_time) + except Exception as e: + logger.exception("루프 내 작업 중 오류: %s", e) + report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 루프 내 작업 실패: {e}", cfg.dry_run) + logger.info("다음 실행까지 %d초 대기", interval_seconds) + time.sleep(interval_seconds) + except KeyboardInterrupt: + logger.info("사용자가 루프를 중단함") + +if __name__ == "__main__": + main() \ No newline at end of file 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..721f1cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +pyupbit +pandas +pandas_ta +requests +python-dotenv +pytest +pykiwoom \ No newline at end of file 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..b0ece25 --- /dev/null +++ b/src/common.py @@ -0,0 +1,26 @@ +import os +import logging +from pathlib import Path +import logging.handlers + +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, "AutoStockTrader.log") + +logger = logging.getLogger("macd_alarm") + +def setup_logger(dry_run: bool): + global logger + logger.handlers.clear() + logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s") + if dry_run: + ch = logging.StreamHandler() + ch.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) + ch.setFormatter(formatter) + logger.addHandler(ch) + fh = logging.handlers.RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=7, encoding="utf-8") + fh.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) + fh.setFormatter(formatter) + logger.addHandler(fh) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..c25dc81 --- /dev/null +++ b/src/config.py @@ -0,0 +1,94 @@ +import os, json +from dataclasses import dataclass +from typing import Optional, Dict, Any +from .common import logger + +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): + with open(p,'r',encoding='utf-8') as f: + cfg = json.load(f) + logger.info('설정 파일 로드: %s', p) + return cfg + for p in example_paths: + if os.path.exists(p): + with open(p,'r',encoding='utf-8') as f: + cfg = json.load(f) + logger.warning('기본 설정 없음; 예제 사용: %s', p) + return cfg + logger.error('설정 파일 없음: config/config.json 확인') + return None + +def read_symbols(path: str) -> list: + syms = [] + try: + with open(path,'r',encoding='utf-8') as f: + for line in f: + s=line.strip() + if not s or s.startswith('#'): continue + syms.append(s) + logger.info('심볼 %d개 로드: %s', len(syms), path) + except Exception as e: + logger.exception('심볼 로드 실패: %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] + kiwoom_account_number: Optional[str] + aggregate_alerts: bool = False + benchmark: bool = False + telegram_test: bool = False + config: Optional[Dict[str, Any]] = None # 원본 config 포함 + + +def build_runtime_config(cfg_dict: dict) -> RuntimeConfig: + # 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" + + return RuntimeConfig( + timeframe=timeframe, + indicator_timeframe=timeframe, + candle_count=int(cfg_dict.get("candle_count", 200)), + symbol_delay=float(cfg_dict.get("symbol_delay", 1.0)), + interval=int(cfg_dict.get("interval", 60)), + loop=bool(cfg_dict.get("loop", False)), + dry_run=bool(cfg_dict.get("dry_run", False)), + max_threads=int(cfg_dict.get("max_threads", 3)), + telegram_parse_mode=cfg_dict.get("telegram_parse_mode"), + trading_mode=cfg_dict.get("trading_mode", "signal_only"), + telegram_bot_token=os.getenv("TELEGRAM_BOT_TOKEN"), + telegram_chat_id=os.getenv("TELEGRAM_CHAT_ID"), + kiwoom_account_number=cfg_dict.get("kiwoom", {}).get("account_number"), + 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..f665f7e --- /dev/null +++ b/src/holdings.py @@ -0,0 +1,245 @@ +import os, json +import threading +from .common import logger +from .kiwoom_api import get_kiwoom_api + +# 스레드 동기화를 위한 Lock 객체 +_holdings_lock = threading.Lock() + +def _unsafe_load_holdings(holdings_file: str) -> dict: + """Lock 없이 홀딩 파일을 로드 (내부용)""" + try: + if os.path.exists(holdings_file): + if os.path.getsize(holdings_file) == 0: + logger.debug("보유 파일이 비어있습니다: %s", holdings_file) + return {} + with open(holdings_file, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError as e: + logger.warning('보유 파일 로드 실패: %s', e) + return {} + +def _unsafe_save_holdings(holdings: dict, holdings_file: str): + """Lock 없이 홀딩 파일을 저장 (내부용)""" + try: + os.makedirs(os.path.dirname(holdings_file) or '.', exist_ok=True) + with open(holdings_file, 'w', encoding='utf-8') as f: + json.dump(holdings, f, ensure_ascii=False, indent=2) + logger.debug('보유 저장: %s', holdings_file) + except Exception as e: + logger.error('보유 저장 실패: %s', e) + +def load_holdings(holdings_file: str = 'holdings.json') -> dict: + """스레드에 안전하게 홀딩 파일을 로드""" + with _holdings_lock: + return _unsafe_load_holdings(holdings_file) + +def save_holdings(holdings: dict, holdings_file: str = 'holdings.json'): + """스레드에 안전하게 홀딩 파일을 저장""" + with _holdings_lock: + _unsafe_save_holdings(holdings, holdings_file) + +def get_kiwoom_balances(account_number: str) -> dict: + try: + api = get_kiwoom_api() + deposit_info, _ = api.get_account_info(account_number) + + balance = 0 + possible_keys = ['d+2추정예수금', 'D+2추정예수금', '예수금', '주문가능금액', '출금가능금액'] + + if isinstance(deposit_info, dict): + for key in possible_keys: + if key in deposit_info: + try: + balance = int(deposit_info[key]) + logger.debug(f'Kiwoom 잔고 조회 성공 (키: {key}): {balance}') + break + except (ValueError, TypeError): + continue + + if balance == 0: + logger.warning(f'예수금 키를 찾을 수 없음. 사용 가능한 키: {list(deposit_info.keys())}') + else: + logger.error(f'deposit_info 형식 오류: {type(deposit_info)}') + + return {'KRW': balance} + except Exception as e: + logger.error('Kiwoom balances 실패: %s', e) + return {} + +def get_current_price(symbol: str) -> float: + try: + api = get_kiwoom_api() + data = api.get_ohlcv(symbol, "D") + + if data is None: + logger.debug('현재가 조회 실패 (None): %s', symbol) + return 0.0 + + if hasattr(data, 'iloc'): + close_col = next((c for c in ['close', '현재가', '종가'] if c in data.columns), None) + if close_col: + price = float(data[close_col].iloc[0]) + else: + logger.warning('close 컬럼 없음: %s, columns: %s', symbol, data.columns.tolist()) + return 0.0 + elif isinstance(data, dict): + close_col = next((c for c in ['close', '현재가', '종가'] if c in data), None) + if close_col: + price_data = data[close_col] + price = float(price_data[0] if isinstance(price_data, (list, tuple)) else price_data) + else: + logger.warning('close 키 없음: %s, keys: %s', symbol, data.keys()) + return 0.0 + else: + logger.warning('알 수 없는 데이터 형식: %s, type: %s', symbol, type(data)) + return 0.0 + + logger.debug('현재가 %s -> %.2f', symbol, price) + return price if price > 0 else 0.0 + except Exception as e: + logger.debug('현재가 실패 %s: %s', symbol, e) + return 0.0 + +def add_new_holding(symbol: str, buy_price: float, amount: float, buy_timestamp: float = None, holdings_file: str = "holdings.json") -> bool: + with _holdings_lock: + try: + import time + holdings = _unsafe_load_holdings(holdings_file) + timestamp = buy_timestamp if buy_timestamp is not None else time.time() + + 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 + + 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("[%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("[%s] holdings 신규 추가: 매수가=%.2f, 수량=%.8f", symbol, buy_price, amount) + + _unsafe_save_holdings(holdings, holdings_file) + return True + except Exception as e: + logger.exception("[%s] holdings 추가 실패: %s", symbol, e) + return False + +def update_holding_amount(symbol: str, amount_change: float, holdings_file: str = "holdings.json") -> bool: + with _holdings_lock: + try: + holdings = _unsafe_load_holdings(holdings_file) + if symbol not in holdings: + logger.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 <= 1e-8: + holdings.pop(symbol, None) + logger.info("[%s] holdings 업데이트: 전량 매도 완료, 보유 제거 (이전: %.8f, 변경: %.8f)", + symbol, prev_amount, amount_change) + else: + holdings[symbol]["amount"] = new_amount + logger.info("[%s] holdings 업데이트: 수량 변경 %.8f -> %.8f (변경량: %.8f)", + symbol, prev_amount, new_amount, amount_change) + + _unsafe_save_holdings(holdings, holdings_file) + return True + except Exception as e: + logger.exception("[%s] holdings 수량 업데이트 실패: %s", symbol, e) + return False + +def set_holding_field(symbol: str, key: str, value, holdings_file: str = "holdings.json") -> bool: + with _holdings_lock: + try: + holdings = _unsafe_load_holdings(holdings_file) + if symbol not in holdings: + logger.warning("[%s] holdings에 존재하지 않아 필드 설정 건너뜀", symbol) + return False + + holdings[symbol][key] = value + logger.info("[%s] holdings 업데이트: 필드 '%s'를 '%s'(으)로 설정", symbol, key, value) + + _unsafe_save_holdings(holdings, holdings_file) + return True + except Exception as e: + logger.exception("[%s] holdings 필드 설정 실패: %s", symbol, e) + return False + +def fetch_holdings_from_kiwoom(account_number: str) -> dict: + try: + api = get_kiwoom_api() + _, balance_info = api.get_account_info(account_number) + + if balance_info is None: + logger.warning('balance_info가 None') + return {} + + holdings = {} + # fetch_holdings_from_kiwoom는 외부 상태를 직접 바꾸지 않고 정보를 가져오므로, + # 기존 holdings.json을 스레드 안전하게 로드합니다. + existing_holdings = load_holdings('holdings.json') + + items = [] + if hasattr(balance_info, 'iterrows'): + items = balance_info.to_dict('records') + elif isinstance(balance_info, list): + items = balance_info + else: + logger.error(f'balance_info 형식 오류: {type(balance_info)}') + return {} + + for item in items: + try: + symbol = item.get('종목번호', item.get('종목코드', '')) + amount = item.get('보유수량', item.get('잔고수량', 0)) + buy_price = item.get('매입가', item.get('평균단가', 0)) + current_price = item.get('현재가', 0) + + if isinstance(amount, str): amount = int(amount.replace('+', '').replace('-', '').strip()) + else: amount = int(amount) + + if isinstance(buy_price, str): buy_price = int(buy_price.replace('+', '').replace('-', '').strip()) + else: buy_price = int(buy_price) + + if isinstance(current_price, str): current_price = int(current_price.replace('+', '').replace('-', '').strip()) + else: current_price = int(current_price) + + if not symbol or amount <= 0: + continue + + prev_max_price = existing_holdings.get(symbol, {}).get('max_price', 0) + max_price = max(current_price, prev_max_price) + + holdings[symbol] = { + 'buy_price': buy_price, + 'amount': amount, + 'max_price': max_price, + 'buy_timestamp': existing_holdings.get(symbol, {}).get('buy_timestamp'), + 'partial_sell_done': existing_holdings.get(symbol, {}).get('partial_sell_done', False), + } + except Exception as e: + logger.warning(f'항목 처리 실패: {e}, item: {item}') + continue + + logger.debug('Kiwoom holdings %d개', len(holdings)) + return holdings + except Exception as e: + logger.error('fetch_holdings 실패: %s', e) + return {} \ No newline at end of file diff --git a/src/indicators.py b/src/indicators.py new file mode 100644 index 0000000..d9851cb --- /dev/null +++ b/src/indicators.py @@ -0,0 +1,192 @@ +import os +import time +import random +import pandas as pd +import pandas_ta as ta +from .common import logger +from .kiwoom_api import get_kiwoom_api + +__all__ = ["fetch_ohlcv", "compute_macd_hist", "compute_sma", "ta"] + +def fetch_ohlcv(symbol: str, timeframe: str, candle_count: int = 200, log_buffer: list = None) -> 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) + _buf("debug", f"OHLCV 수집 시작: {symbol} {timeframe}") + + # Kiwoom API 타임프레임 매핑 + # 분봉: "1", "3", "5", "15", "30", "60" (분 단위) + # 일/주/월봉: "D", "W", "M" + # 주의: 4시간봉은 Kiwoom API가 직접 지원하지 않으므로 60분봉 데이터를 리샘플링 + tf_map = { + "1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30", + "1h": "60", + "4h": "60", # 60분봉 데이터를 가져와서 4시간으로 리샘플링 + "D": "D", "W": "W", "M": "M" + } + + # 리샘플링이 필요한 타임프레임 체크 + needs_resampling = timeframe == "4h" + original_timeframe = timeframe + original_candle_count = candle_count + + # 4시간봉은 60분봉 4개를 합쳐야 하므로 candle_count를 4배로 조정 + if needs_resampling: + candle_count = candle_count * 4 + _buf("debug", f"4시간봉 요청으로 candle_count 조정: {original_candle_count} -> {candle_count}") + + tf = tf_map.get(timeframe, "D") # 기본값 'D' (일봉) + + if timeframe not in tf_map: + _buf("warning", f"지원하지 않는 타임프레임 '{timeframe}', 일봉(D)으로 대체") + needs_resampling = False + candle_count = original_candle_count + + 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: + api = get_kiwoom_api() + df = api.get_ohlcv(symbol, tf) + + if df is None or (hasattr(df, 'empty') and df.empty): + _buf("warning", f"OHLCV 빈 결과: {symbol}") + raise RuntimeError("empty ohlcv") + + # 컬럼명 확인 및 변경 + # opt10081 (일/주/월봉): '일자', '시가', '고가', '저가', '현재가', '거래량' 등 + # opt10080 (분봉): '체결시간', '시가', '고가', '저가', '현재가', '거래량' 등 + column_mapping = { + '일자': 'timestamp', # 일/주/월봉 + '체결시간': 'timestamp', # 분봉 + '시간': 'timestamp', # 대체 가능한 컬럼명 + '시가': 'open', + '고가': 'high', + '저가': 'low', + '현재가': 'close', + '종가': 'close', # 일부 응답에서 '종가'를 사용할 수 있음 + '거래량': 'volume' + } + + # 존재하는 컬럼만 rename + rename_dict = {k: v for k, v in column_mapping.items() if k in df.columns} + df = df.rename(columns=rename_dict) + + # 필수 컬럼 확인 + required_cols = ['timestamp', 'open', 'high', 'low', 'close', 'volume'] + missing_cols = [col for col in required_cols if col not in df.columns] + if missing_cols: + _buf("error", f"필수 컬럼 누락: {missing_cols}, 존재하는 컬럼: {df.columns.tolist()}") + raise RuntimeError(f"missing required columns: {missing_cols}") + + # timestamp 처리 + # 분봉: 'YYYYMMDDHHMMSS' (14자리), 일/주/월봉: 'YYYYMMDD' (8자리) + if df['timestamp'].dtype == 'object': + # 첫 번째 값으로 형식 판별 + first_value = str(df['timestamp'].iloc[0]).strip() + if len(first_value) >= 14: # 분봉 데이터 (YYYYMMDDHHMMSS) + df['timestamp'] = pd.to_datetime(df['timestamp'], format='%Y%m%d%H%M%S', errors='coerce') + _buf("debug", f"분봉 timestamp 파싱: {first_value[:14]}") + else: # 일/주/월봉 데이터 (YYYYMMDD) + df['timestamp'] = pd.to_datetime(df['timestamp'], format='%Y%m%d', errors='coerce') + _buf("debug", f"일/주/월봉 timestamp 파싱: {first_value}") + df.set_index('timestamp', inplace=True) + + # timestamp 파싱 실패 확인 + if df.index.isnull().any(): + null_count = df.index.isnull().sum() + _buf("warning", f"timestamp 파싱 실패한 행 {null_count}개 발견, 제거합니다") + df = df[df.index.notnull()] + + # 데이터 타입을 float으로 변환 + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce').astype(float) + + # NaN 값이 있는 행 제거 + df = df.dropna() + + if df.empty: + _buf("warning", f"데이터 변환 후 빈 결과: {symbol}") + raise RuntimeError("empty after conversion") + + # 4시간봉 리샘플링 처리 + if needs_resampling and original_timeframe == "4h": + try: + _buf("debug", f"4시간봉 리샘플링 시작: {symbol}, 원본 rows={len(df)}") + + # 4시간 간격으로 리샘플링 (OHLCV 재집계) + df_4h = df.resample('4H').agg({ + 'open': 'first', # 시가: 첫 번째 값 + 'high': 'max', # 고가: 최대값 + 'low': 'min', # 저가: 최소값 + 'close': 'last', # 종가: 마지막 값 + 'volume': 'sum' # 거래량: 합계 + }).dropna() + + if df_4h.empty: + _buf("warning", f"4시간봉 리샘플링 후 빈 데이터: {symbol}, 원본 데이터 사용") + else: + df = df_4h + _buf("debug", f"4시간봉 리샘플링 완료: {symbol}, rows={len(df)}") + except Exception as e: + _buf("warning", f"4시간봉 리샘플링 실패: {symbol}, {e} - 60분봉 데이터 사용") + + # 리샘플링된 경우 원래 요청한 candle_count로 제한 + final_candle_count = original_candle_count if needs_resampling else candle_count + _buf("debug", f"OHLCV 수집 완료: {symbol}, timeframe={original_timeframe}, rows={len(df)}, returning={min(final_candle_count, len(df))}") + return df.head(final_candle_count) + except Exception as e: + _buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}") + if attempt == max_attempts: + _buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})") + return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp")) + + 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) + return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp")) + + cumulative_sleep += sleep_time + _buf("debug", f"{sleep_time:.2f}초 후 재시도") + time.sleep(sleep_time) + + return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp")) + + +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") + return pd.Series([]) + return macd_df[hist_cols[0]] + except Exception as e: + _buf("error", f"MACD 계산 실패: {e}") + return pd.Series([]) + + +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}") + return pd.Series([]) \ No newline at end of file diff --git a/src/kiwoom_api.py b/src/kiwoom_api.py new file mode 100644 index 0000000..daef2ca --- /dev/null +++ b/src/kiwoom_api.py @@ -0,0 +1,223 @@ +import os +import sys +import threading +from PyQt5.QtWidgets import QApplication +from pykiwoom.kiwoom import * +import logging + +logger = logging.getLogger(__name__) + +class KiwoomAPI: + def __init__(self): + self.kiwoom = Kiwoom() + self._login_lock = threading.Lock() + self._last_connection_check = 0 + self._connection_check_interval = 60 # 연결 상태 확인 간격 (60초) + self.login() + + def login(self): + """Kiwoom API 로그인""" + with self._login_lock: + try: + self.kiwoom.CommConnect(block=True) + logger.info("Kiwoom API에 로그인 성공") + return True + except Exception as e: + logger.error(f"Kiwoom API 로그인 실패: {e}") + return False + + def is_connected(self) -> bool: + """ + 현재 연결 상태 확인 + 반환값: 0=연결안됨, 1=연결완료 + """ + try: + state = self.kiwoom.GetConnectState() + return state == 1 + except Exception as e: + logger.warning(f"연결 상탄 확인 실패: {e}") + return False + + def ensure_connected(self) -> bool: + """ + 연결 상탈 확인 및 필요시 재연결 + 주기적으로 호출하도록 간격 제한 포함 + """ + import time + current_time = time.time() + + # 마지막 확인 후 일정 시간이 지나지 않았으면 검사 생략 + if current_time - self._last_connection_check < self._connection_check_interval: + return True + + self._last_connection_check = current_time + + if self.is_connected(): + logger.debug("연결 상탄 정상") + return True + + logger.warning("Kiwoom API 연결이 끊어졌습니다. 재연결을 시도합니다...") + + # 최대 3회 재시도 + for attempt in range(1, 4): + logger.info(f"재연결 시도 {attempt}/3") + + if self.login(): + if self.is_connected(): + logger.info("재연결 성공") + return True + + if attempt < 3: + wait_time = attempt * 5 # 5, 10초 대기 + logger.info(f"{wait_time}초 후 재시도...") + time.sleep(wait_time) + + logger.error("재연결 실패: 3회 모두 실패") + + # 텔레그램 알림 (환경변수에서 토큰 가져오기) + try: + bot_token = os.getenv("TELEGRAM_BOT_TOKEN") + chat_id = os.getenv("TELEGRAM_CHAT_ID") + if bot_token and chat_id: + import requests + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + message = "🔴 [긴급] Kiwoom API 재연결 실패\n\n3회 재시도 모두 실패했습니다.\n프로그램 관리자의 확인이 필요합니다." + requests.post(url, json={"chat_id": chat_id, "text": message}, timeout=10) + except Exception as e: + logger.warning(f"재연결 실패 알림 전송 실패: {e}") + + return False + + def get_account_info(self, account_number): + """ + 계좌 정보 조회 (예수금 및 잔고) + """ + # 연결 상탄 확인 및 재연결 + if not self.ensure_connected(): + raise ConnectionError("Kiwoom API 연결이 끊어졌고 재연결에 실패했습니다") + + try: + # 예수금 정보 + deposit_info = self.kiwoom.block_request("opw00001", + 계좌번호=account_number, + 비밀번호="", + 비밀번호입력매체구분="00", + 조회구분=2, + output="예수금상세현황", + next=0) + + # 계좌평가잔고내역 + balance_info = self.kiwoom.block_request("opw00018", + 계좌번호=account_number, + 비밀번호="", + 비밀번호입력매체구분="00", + 조회구분=1, + output="계좌평가잔고개별합산", + next=0) + return deposit_info, balance_info + except Exception as e: + logger.error(f"계좌 정보 조회 실패: {e}") + raise + + def get_ohlcv(self, code, timeframe): + """ + OHLCV 데이터 조회 + timeframe: '1', '3', '5', ... (분봉), 'D'(일봉), 'W'(주봉), 'M'(월봉) + """ + # 연결 상탄 확인 및 재연결 + if not self.ensure_connected(): + logger.error(f"API 연결 끊김: {code} OHLCV 조회 실패") + return None + + try: + # timeframe이 숫자인지 문자인지 확인하여 API 분기 + if timeframe.isdigit(): + # 분봉/시간봉 데이터 요청 (opt10080) + df = self.kiwoom.block_request("opt10080", + 종목코드=code, + 틱범위=timeframe, + 수정주가구분=1, + output="주식분봉차트조회", + next=0) + logger.debug(f"분봉 데이터 요청: {code}, {timeframe}분") + else: + # 일/주/월봉 데이터 요청 (opt10081) + df = self.kiwoom.block_request("opt10081", + 종목코드=code, + 틱범위=timeframe, + 수정주가구분=1, + output="주식일봉차트조회", + next=0) + logger.debug(f"일/주/월봉 데이터 요청: {code}, {timeframe}") + + if df is None or (hasattr(df, 'empty') and df.empty): + logger.warning(f"OHLCV 조회 결과 없음: {code} ({timeframe})") + return None + return df + except Exception as e: + logger.error(f"OHLCV 조회 실패: {code}, {timeframe}, {e}") + return None + + def send_order(self, order_type, code, quantity, price, account_number): + """ + 주문 실행 + order_type: 1=신규매수, 2=신규매도, 3=매수취소, 4=매도취소, 5=매수정정, 6=매도정정 + code: 종목코드 (6자리) + quantity: 주문수량 + price: 주문가격 (0이면 시장가) + """ + # 연결 상탄 확인 및 재연결 (주문은 매우 중요하므로 반드시 확인) + if not self.ensure_connected(): + error_msg = f"주문 실패: API 연결이 끊어졌고 재연결에 실패했습니다" + logger.error(error_msg) + raise ConnectionError(error_msg) + + try: + # 시장가 주문의 경우 가격을 0으로, 지정가 주문의 경우 해당 가격으로 설정 + price_to_send = 0 if price == 0 else int(price) + # 거래구분: 지정가="00", 시장가="03" + order_price_type = "03" if price == 0 else "00" + + result = self.kiwoom.SendOrder( + "자동매매", # 사용자구분명 + "0101", # 화면번호 + account_number, # 계좌번호 + order_type, # 주문유형 + code, # 종목코드 + quantity, # 주문수량 + price_to_send, # 주문가격 + order_price_type, # 거래구분 (00: 지정가, 03: 시장가) + "" # 원주문번호 + ) + logger.info(f"주문 실행: type={order_type}, code={code}, qty={quantity}, price={price_to_send}, order_type={order_price_type}, result={result}") + return result + except Exception as e: + logger.error(f"주문 실행 실패: {e}") + raise + +# Singleton instance +kiwoom_instance = None + +def get_kiwoom_api(): + global kiwoom_instance + if kiwoom_instance is None: + app = QApplication.instance() + if not app: + app = QApplication(sys.argv) + kiwoom_instance = KiwoomAPI() + return kiwoom_instance + +if __name__ == '__main__': + # For testing purposes + app = QApplication(sys.argv) + api = get_kiwoom_api() + + # Example: Get account info + account_num = "YOUR_ACCOUNT_NUMBER" # 실제 계좌번호로 변경 필요 + deposit, balance = api.get_account_info(account_num) + print("예수금 정보:", deposit) + print("계좌잔고:", balance) + + # Example: Get OHLCV data + ohlcv = api.get_ohlcv("005930", "D") # 삼성전자, 일봉 + print("삼성전자 일봉 데이터:", ohlcv) diff --git a/src/notifications.py b/src/notifications.py new file mode 100644 index 0000000..200fcdc --- /dev/null +++ b/src/notifications.py @@ -0,0 +1,67 @@ +import threading +import requests +import time +from .common import logger + +__all__ = ["send_telegram", "report_error", "send_startup_test_message"] + +def send_telegram(bot_token: str, chat_id: str, text: str, add_thread_prefix: bool = True, parse_mode: str = None) -> bool: + if add_thread_prefix: + thread_name = threading.current_thread().name + payload_text = f"[{thread_name}] {text}" + else: + payload_text = text + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = {"chat_id": chat_id, "text": payload_text} + if parse_mode: + payload["parse_mode"] = parse_mode + max_attempts = 4 + backoff = 1.0 + cumulative_sleep = 0.0 + for attempt in range(1, max_attempts + 1): + try: + resp = requests.post(url, json=payload, timeout=10) + if resp.status_code == 200: + return True + else: + logger.warning("텔레그램 전송 실패 (시도 %d/%d) status=%s", attempt, max_attempts, resp.status_code) + except Exception as e: + logger.warning("텔레그램 예외 (시도 %d/%d): %s", attempt, max_attempts, e) + if attempt == max_attempts: + logger.error("텔레그램 최대 재시도 도달") + return False + sleep_time = backoff * attempt + cumulative_sleep += sleep_time + if cumulative_sleep > 30: + logger.error("텔레그램 누적 대기시간 초과") + return False + time.sleep(sleep_time) + return False + +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: + try: + send_telegram(bot_token, chat_id, message) + except Exception: + logger.exception("에러 알림 전송 실패") + +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(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..02ce39e --- /dev/null +++ b/src/order.py @@ -0,0 +1,317 @@ +import os +import time +import json +import secrets +from .common import logger +from .notifications import send_telegram +from .kiwoom_api import get_kiwoom_api + +def _make_confirm_token(length: int = 16) -> str: + return secrets.token_hex(length) + +def _write_pending_order(token: str, order: dict, pending_file: str = "pending_orders.json"): + 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) + +def _check_confirmation(token: str, timeout: int = 300) -> bool: + start = time.time() + confirm_file = f"confirm_{token}" + tokens_file = "confirmed_tokens.txt" + while time.time() - start < timeout: + if os.path.exists(confirm_file): + try: + os.remove(confirm_file) + except Exception: + pass + return True + if os.path.exists(tokens_file): + try: + with open(tokens_file, "r", encoding="utf-8") as f: + for line in f: + if line.strip() == token: + return True + except Exception: + pass + time.sleep(2) + return False + +def notify_order_result(symbol: str, order_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 {} + status = order_result.get("status", "unknown") + + should_notify = False + msg = "" + if status == "placed" and notify_cfg.get("order_filled", True): + should_notify = True + msg = f"[주문완료] {symbol}\n상태: 주문 성공" + elif status in ("error", "failed") and notify_cfg.get("order_error", True): + should_notify = True + msg = f"[주문오류] {symbol}\n상태: {status}\n에러: {order_result.get('error')}" + + 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, sell_price: float): + try: + from .holdings import load_holdings + holdings = load_holdings("holdings.json") + if symbol not in holdings: + return + + buy_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0) + + 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_kiwoom(market: str, amount: int, config: dict, account_number: str, dry_run: bool = True) -> dict: + from .holdings import get_current_price + + if dry_run: + price = get_current_price(market) + quantity = int(amount // price) if price > 0 else 0 + logger.info("[place_buy_order_kiwoom][dry-run] %s 매수 금액=%d, 예상 수량=%d", market, amount, quantity) + return {"market": market, "side": "buy", "amount": amount, "price": price, "status": "simulated", "timestamp": time.time()} + + if not account_number: + msg = "Kiwoom 계좌번호 없음: 매수 주문을 실행할 수 없습니다" + logger.error(msg) + return {"error": msg, "status": "failed", "timestamp": time.time()} + + try: + api = get_kiwoom_api() + price = get_current_price(market) + if price <= 0: + msg = f"현재가 조회 실패로 매수 불가: {market}" + logger.error(msg) + return {"error": msg, "status": "failed", "timestamp": time.time()} + + quantity = int(amount // price) + + min_order_value = config.get("auto_trade", {}).get("min_order_value", 100000) + if amount < min_order_value: + msg = f"시장가 매수 건너뜀: 주문 금액 {amount} < 최소 {min_order_value}" + logger.warning(msg) + return {"market": market, "side": "buy", "amount": amount, "status": "skipped_too_small", "reason": "min_order_value", "timestamp": time.time()} + + # 시장가 매수 주문 (가격 0) + order_result = api.send_order(1, market, quantity, 0, account_number) + + logger.info("Kiwoom 시장가 매수 주문: %s, 금액=%d, 수량=%d", market, amount, quantity) + logger.info("Kiwoom 주문 응답: %s", order_result) + + status = "placed" if order_result == 0 else "failed" + result = {"market": market, "side": "buy", "amount": amount, "quantity": quantity, "status": status, "response": order_result, "timestamp": time.time()} + + return result + except Exception as e: + logger.exception("Kiwoom 매수 주문 실패: %s", e) + return {"error": str(e), "status": "failed", "timestamp": time.time()} + +def place_sell_order_kiwoom(market: str, quantity: int, config: dict, account_number: str, dry_run: bool = True) -> dict: + """ + 매도 주문 실행 + market: 종목코드 + quantity: 매도 수량 (주) + """ + if dry_run: + logger.info("[place_sell_order_kiwoom][dry-run] %s 매도 수량=%d", market, quantity) + return {"market": market, "side": "sell", "quantity": quantity, "status": "simulated", "timestamp": time.time()} + + if not account_number: + msg = "Kiwoom 계좌번호 없음: 매도 주문을 실행할 수 없습니다" + logger.error(msg) + return {"error": msg, "status": "failed", "timestamp": time.time()} + + try: + api = get_kiwoom_api() + + # 매도 수량 검증 + if quantity <= 0: + msg = f"잘못된 매도 수량: {quantity}" + logger.error(msg) + return {"error": msg, "status": "failed", "timestamp": time.time()} + + # 시장가 매도 주문 (가격 0) + order_result = api.send_order(2, market, int(quantity), 0, account_number) + + logger.info("Kiwoom 시장가 매도 주문: %s, 수량=%d", market, quantity) + logger.info("Kiwoom 주문 응답: %s", order_result) + + status = "placed" if order_result == 0 else "failed" + result = {"market": market, "side": "sell", "quantity": quantity, "status": status, "response": order_result, "timestamp": time.time()} + + return result + except Exception as e: + logger.exception("Kiwoom 매도 주문 실패: %s", e) + return {"error": str(e), "status": "failed", "timestamp": time.time()} + +def execute_sell_order_with_confirmation(symbol: str, quantity: int, config: dict, telegram_token: str, telegram_chat_id: str, account_number: str, dry_run: bool, parse_mode: str = "HTML") -> dict: + """ + 확인 절차를 거쳐 매도 주문 실행 + symbol: 종목코드 + quantity: 매도 수량 (주) + """ + confirm_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_kiwoom(symbol, quantity, config, account_number, dry_run) + else: + token = _make_confirm_token() + order_info = {"symbol": symbol, "side": "sell", "quantity": quantity, "timestamp": time.time()} + _write_pending_order(token, order_info) + + if parse_mode == "HTML": + msg = f"[확인필요] 자동매도 주문 대기\n" + msg += f"토큰: {token}\n" + msg += f"심볼: {symbol}\n" + msg += f"매도수량: {quantity:,d}주\n\n" + msg += f"확인 방법:\n" + msg += f"1. 파일 생성: confirm_{token}\n" + msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n" + msg += f"타임아웃: {confirm_timeout}초" + else: + msg = f"[확인필요] 자동매도 주문 대기\n" + msg += f"토큰: {token}\n" + msg += f"심볼: {symbol}\n" + msg += f"매도수량: {quantity:,d}주\n\n" + msg += f"확인 방법:\n" + msg += f"1. 파일 생성: confirm_{token}\n" + msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n" + msg += f"타임아웃: {confirm_timeout}초" + + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False, parse_mode=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 telegram_token and telegram_chat_id: + cancel_msg = f"[주문취소] {symbol} 매도\n사유: 사용자 미확인 (타임아웃)" + send_telegram(telegram_token, telegram_chat_id, cancel_msg, add_thread_prefix=False, parse_mode=parse_mode) + result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()} + else: + logger.info("[%s] 매도 확인 완료: 주문 실행", symbol) + result = place_sell_order_kiwoom(symbol, quantity, config, account_number, dry_run) + + if result: + notify_order_result(symbol, result, config, telegram_token, telegram_chat_id) + + trade_status = result.get("status") + if trade_status in ["simulated", "placed", "user_not_confirmed"]: + from .holdings import get_current_price + sell_price = get_current_price(symbol) if trade_status == "placed" else 0 + + trade_record = {"symbol": symbol, "side": "sell", "quantity": quantity, "timestamp": time.time(), "dry_run": dry_run, "result": result} + + _calculate_and_add_profit_rate(trade_record, symbol, sell_price) + from .signals import record_trade + record_trade(trade_record) + + if not dry_run and result.get("status") == "placed": + from .holdings import update_holding_amount + update_holding_amount(symbol, -quantity, "holdings.json") + + return result + +def execute_buy_order_with_confirmation(symbol: str, amount: int, config: dict, telegram_token: str, telegram_chat_id: str, account_number: str, dry_run: bool, parse_mode: str = "HTML") -> dict: + confirm_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_kiwoom(symbol, amount, config, account_number, dry_run) + else: + token = _make_confirm_token() + order_info = {"symbol": symbol, "side": "buy", "amount": amount, "timestamp": time.time()} + _write_pending_order(token, order_info) + + if parse_mode == "HTML": + msg = f"[확인필요] 자동매수 주문 대기\n" + msg += f"토큰: {token}\n" + msg += f"심볼: {symbol}\n" + msg += f"매수금액: {amount:,.0f}\n\n" + msg += f"확인 방법:\n" + msg += f"1. 파일 생성: confirm_{token}\n" + msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n" + msg += f"타임아웃: {confirm_timeout}초" + else: + msg = f"[확인필요] 자동매수 주문 대기\n" + msg += f"토큰: {token}\n" + msg += f"심볼: {symbol}\n" + msg += f"매수금액: {amount:,.0f}\n\n" + msg += f"확인 방법:\n" + msg += f"1. 파일 생성: confirm_{token}\n" + msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n" + msg += f"타임아웃: {confirm_timeout}초" + + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False, parse_mode=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 telegram_token and telegram_chat_id: + cancel_msg = f"[주문취소] {symbol} 매수\n사유: 사용자 미확인 (타임아웃)" + send_telegram(telegram_token, telegram_chat_id, cancel_msg, add_thread_prefix=False, parse_mode=parse_mode) + result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()} + else: + logger.info("[%s] 매수 확인 완료: 주문 실행", symbol) + result = place_buy_order_kiwoom(symbol, amount, config, account_number, dry_run) + + if result: + notify_order_result(symbol, result, config, telegram_token, telegram_chat_id) + + trade_status = result.get("status") + if trade_status in ["simulated", "placed", "user_not_confirmed"]: + trade_record = { + "symbol": symbol, + "side": "buy", + "amount": amount, + "timestamp": time.time(), + "dry_run": dry_run, + "result": result + } + from .signals import record_trade + record_trade(trade_record) + + return result \ No newline at end of file diff --git a/src/signals.py b/src/signals.py new file mode 100644 index 0000000..2a86cce --- /dev/null +++ b/src/signals.py @@ -0,0 +1,551 @@ +import os +import time +import json +import inspect +from typing import List, Dict, Tuple, Any +from enum import Enum +import pandas as pd +import pandas_ta as ta +from datetime import datetime + +from .common import logger +from .config import RuntimeConfig +from .indicators import fetch_ohlcv, compute_macd_hist, compute_sma +from .holdings import fetch_holdings_from_kiwoom, get_current_price +from .notifications import send_telegram + + +class CheckType(str, Enum): + """매도 조건 체크 타입.""" + STOP_LOSS = "stop_loss" # 손절 조건 (1시간 주기) + PROFIT_TAKING = "profit_taking" # 익절 조건 (4시간 주기) + ALL = "all" # 모든 조건 + + +def make_trade_record(symbol, side, amount, dry_run, price=None, status="simulated"): + now = float(time.time()) + return { + "symbol": symbol, + "side": side, + "amount": amount, + "timestamp": now, + "datetime": datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S"), + "dry_run": dry_run, + "result": { + "market": symbol, + "side": side, + "amount": amount, + "price": price, + "status": status, + "timestamp": now + } + } + +def evaluate_sell_conditions( + current_price: float, + buy_price: float, + max_price: float, + holding_info: Dict[str, Any], + config: Dict[str, Any] | None = None, + check_type: str = CheckType.ALL +) -> Dict[str, Any]: + """매도 조건을 평가하여 매도 여부와 비율을 반환. + + Args: + current_price: 현재 가격 + buy_price: 매수 가격 + max_price: 최고 가격 + holding_info: 보유 정보 (partial_sell_done 포함) + config: 설정 딕셔너리 + check_type: 체크 타입 (CheckType.STOP_LOSS, CheckType.PROFIT_TAKING, CheckType.ALL) + + Returns: + 매도 조건 평가 결과 딕셔너리 + """ + config = config or {} + auto_trade_config = config.get("auto_trade", {}) + loss_threshold = float(auto_trade_config.get("loss_threshold", -5.0)) + profit_threshold_1 = float(auto_trade_config.get("profit_threshold_1", 10.0)) + profit_threshold_2 = float(auto_trade_config.get("profit_threshold_2", 30.0)) + drawdown_1 = float(auto_trade_config.get("drawdown_1", 5.0)) + drawdown_2 = float(auto_trade_config.get("drawdown_2", 15.0)) + partial_sell_ratio = float(auto_trade_config.get("partial_sell_ratio", 0.5)) + + # 가격 유효성 검증 + if buy_price <= 0: + logger.error(f"비정상 매수가: {buy_price}") + return { + "status": "error", + "sell_ratio": 0.0, + "reasons": [f"비정상 매수가: {buy_price}"], + "profit_rate": 0, + "max_drawdown": 0, + "set_partial_sell_done": False, + "check_interval_minutes": 60 + } + + if max_price <= 0: + logger.warning(f"비정상 최고가: {max_price}, 현재가로 대체") + max_price = current_price + + profit_rate = ((current_price - buy_price) / buy_price) * 100 + max_drawdown = ((current_price - max_price) / max_price) * 100 + + result = { + "status": "hold", + "sell_ratio": 0.0, + "reasons": [], + "profit_rate": profit_rate, + "max_drawdown": max_drawdown, + "set_partial_sell_done": False, + "check_interval_minutes": 60 # 기본값: 1시간 + } + + # check_type에 따른 조건 필터링 + # "stop_loss": 1시간 주기 조건만 (조건1, 조건3, 조건4-2, 조건5-2) + # "profit_taking": 4시간 주기 조건만 (조건2, 조건4-1, 조건5-1) + # "all": 모든 조건 + + # 조건 우선순위: + # 1. 손절 (조건1) - 최우선 + # 2. 부분 익절 (조건3) - 수익 실현 시작 (1회성) + # 3. 전량 익절 (조건4, 5) - 최고점 대비 하락 또는 수익률 하락 + + # 조건1: 손절 -5% (1시간 주기) - 손실 방지 최우선 + if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= loss_threshold: + result.update(status="stop_loss", sell_ratio=1.0, check_interval_minutes=60) + result["reasons"].append(f"손절(조건1): 수익률 {profit_rate:.2f}% <= {loss_threshold}%") + return result + + max_profit_rate = ((max_price - buy_price) / buy_price) * 100 + + # 조건3: 부분 익절 10% (1시간 주기) - profit_threshold_1 도달 시 일부 수익 확정 + # 주의: 부분 익절은 1회만 실행되며, 이후 전량 익절 조건만 평가됨 + partial_sell_done = holding_info.get("partial_sell_done", False) + if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and not partial_sell_done and profit_rate >= profit_threshold_1: + result.update(status="partial_profit", sell_ratio=partial_sell_ratio, check_interval_minutes=60) + result["reasons"].append(f"부분 익절(조건3): 수익률 {profit_rate:.2f}% 달성, {int(partial_sell_ratio * 100)}% 매도") + result["set_partial_sell_done"] = True + return result + + if max_profit_rate > profit_threshold_2: + # 조건5-1: 최고점 -15% 하락 (4시간 주기) + if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_2: + result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=240) + result["reasons"].append(f"전량 익절(조건5-1): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_2}%)") + return result + # 조건5-2: 수익률 30% 이하 하락 (1시간 주기) + if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= profit_threshold_2: + result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=60) + result["reasons"].append(f"전량 익절(조건5-2): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 수익률 {profit_rate:.2f}%로 하락 (기준: {profit_threshold_2}%)") + return result + elif profit_threshold_1 < max_profit_rate <= profit_threshold_2: + # 조건4-1: 최고점 -5% 하락 (4시간 주기) + if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_1: + result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=240) + result["reasons"].append(f"전량 익절(조건4-1): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)") + return result + # 조건4-2: 수익률 10% 이하 하락 (1시간 주기) + if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= profit_threshold_1: + result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=60) + result["reasons"].append(f"전량 익절(조건4-2): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 수익률 {profit_rate:.2f}%로 하락 (기준: {profit_threshold_1}%)") + return result + elif max_profit_rate <= profit_threshold_1: + # 조건2: 10% 이하 + 최고점 -5% (4시간 주기) + if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_1: + result.update(status="stop_loss", sell_ratio=1.0, check_interval_minutes=240) + 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 "사유 없음" + + 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" + 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" + return msg + +def _adjust_sell_ratio_for_min_order(symbol: str, total_amount: float, sell_ratio: float, current_price: float, config: dict) -> float: + if not (0 < sell_ratio < 1): + return sell_ratio + + auto_trade_cfg = (config or {}).get("auto_trade", {}) + try: + min_order_value = float(auto_trade_cfg.get("min_order_value", 100000)) + except (ValueError, TypeError): + min_order_value = 100000.0 + + amount_to_sell_partial = total_amount * sell_ratio + value_to_sell = amount_to_sell_partial * current_price + value_remaining = (total_amount - amount_to_sell_partial) * current_price + + if value_to_sell < min_order_value or value_remaining < min_order_value: + logger.info("[%s] 부분 매도(%.0f%%) 조건 충족했으나, 최소 주문 금액(%.0f) 문제로 전량 매도로 전환합니다. (예상 매도액: %.0f, 예상 잔여액: %.0f)", + 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.json"): + try: + trades = [] + if os.path.exists(trades_file): + with open(trades_file, "r", encoding="utf-8") as f: + try: + trades = json.load(f) + except Exception: + trades = [] + trades.append(trade) + with open(trades_file, "w", encoding="utf-8") as f: + json.dump(trades, f, ensure_ascii=False, indent=2) + logger.debug("거래기록 저장됨: %s", trades_file) + except Exception as e: + logger.exception("거래기록 저장 실패: %s", e) + +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 계산 + interval_seconds = 0 + try: + if 'm' in timeframe: + interval_seconds = int(timeframe.replace('m', '')) * 60 + elif 'h' in timeframe: + interval_seconds = int(timeframe.replace('h', '')) * 3600 + elif timeframe == 'D': + interval_seconds = 86400 + elif timeframe == 'W': + interval_seconds = 86400 * 7 + elif timeframe == 'M': + # 월봉은 정확한 계산이 어려우므로 업데이트 건너뜀 + buffer.append("warning: 월봉(M)은 실시간 업데이트를 지원하지 않습니다") + return df + except (ValueError, AttributeError) as e: + buffer.append(f"warning: 타임프레임 파싱 실패 ({timeframe}): {e}") + return df + + 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}, timeframe={timeframe}") + else: + buffer.append(f"info: 새로운 캔들 시작됨, 실시간 업데이트 건너뜀 (timeframe={timeframe})") + else: + buffer.append(f"warning: interval_seconds 계산 실패 (timeframe={timeframe})") + except Exception as e: + buffer.append(f"warning: 실시간 캔들 업데이트 실패: {e}") + return df + +def process_symbol(symbol: str, timeframe: str, candle_count: int, telegram_token: str, telegram_chat_id: str, dry_run: bool, indicators: dict = None, indicator_timeframe: str = None, cfg: RuntimeConfig | None = None) -> dict: + result = {"symbol": symbol, "summary": [], "telegram": None, "error": None} + try: + if cfg is not None: + timeframe = timeframe or cfg.timeframe + candle_count = candle_count or cfg.candle_count + telegram_token = telegram_token or cfg.telegram_bot_token + telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id + dry_run = cfg.dry_run if dry_run is None else dry_run + indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe + buffer = [] + use_tf = indicator_timeframe or timeframe + + df = fetch_ohlcv(symbol, use_tf, candle_count=candle_count, log_buffer=buffer) + df = _update_df_with_realtime_price(df, symbol, use_tf, buffer) + + if buffer: + for b in buffer: + result["summary"].append(b) + if df.empty or len(df) < 3: + result["summary"].append(f"MACD 계산에 충분한 데이터 없음: {symbol}") + result["error"] = "insufficient_data" + return result + + hist = compute_macd_hist(df["close"], log_buffer=buffer) + + if buffer and len(result["summary"]) == 0: + for b in buffer: + result["summary"].append(b) + if len(hist.dropna()) < 2: + result["summary"].append(f"MACD 히스토그램 값 부족: {symbol}") + result["error"] = "insufficient_macd" + return result + + 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)) + adx_threshold = float(ind.get("adx_threshold", 25)) + 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) + cols = list(macd_df.columns) + hist_cols = [c for c in cols if "MACDh" in c or "hist" in c.lower()] + macd_cols = [c for c in cols if ("MACD" in c and c not in hist_cols and not c.lower().endswith("s"))] + signal_cols = [c for c in cols if ("MACDs" in c or c.lower().endswith("s") or "signal" in c.lower())] + + if not macd_cols or not signal_cols: + if len(cols) >= 3: + macd_col = cols[0] + signal_col = cols[-1] + else: + raise RuntimeError("MACD columns not found") + else: + macd_col = macd_cols[0] + signal_col = signal_cols[0] + + macd_line = macd_df[macd_col].dropna() + signal_line = macd_df[signal_col].dropna() + + 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()] + adx_series = adx_df[adx_cols[0]].dropna() if adx_cols else pd.Series([]) + + if macd_line is None or signal_line is None: + if hist is not None and len(hist.dropna()) >= 2: + slope = float(hist.dropna().iloc[-1] - hist.dropna().iloc[-2]) + if slope > 0: + close_price = float(df["close"].iloc[-1]) + result["summary"].append(f"매수 신호발생 (히스토그램 기울기): {symbol}") + result["telegram"] = f"매수 신호발생: {symbol} -> 히스토그램 기울기 양수\n가격: {close_price:.2f}\n사유: histogram slope {slope:.6f}" + return result + result["summary"].append("조건 미충족: 히스토그램 기울기 음수") + result["error"] = "insufficient_macd" + return result + result["summary"].append("MACD 데이터 부족: 교차 판별 불가") + result["error"] = "insufficient_macd" + return result + + if len(macd_line) < 2 or len(signal_line) < 2: + result["summary"].append("MACD 데이터 부족: 교차 판별 불가") + return result + + close = float(df["close"].iloc[-1]) + prev_macd = float(macd_line.iloc[-2]) + curr_macd = float(macd_line.iloc[-1]) + prev_signal = float(signal_line.iloc[-2]) + curr_signal = float(signal_line.iloc[-1]) + prev_sma_short = float(sma_short.dropna().iloc[-2]) if len(sma_short.dropna()) >= 2 else None + curr_sma_short = float(sma_short.dropna().iloc[-1]) if len(sma_short.dropna()) >= 1 else None + prev_sma_long = float(sma_long.dropna().iloc[-2]) if len(sma_long.dropna()) >= 2 else None + curr_sma_long = float(sma_long.dropna().iloc[-1]) if len(sma_long.dropna()) >= 1 else None + prev_adx = float(adx_series.iloc[-2]) if len(adx_series) >= 2 else None + curr_adx = float(adx_series.iloc[-1]) if len(adx_series) >= 1 else None + + cross_macd_signal = (prev_macd < prev_signal and curr_macd > curr_signal) + cross_macd_zero = (prev_macd < 0 and curr_macd > 0) + macd_cross_ok = (cross_macd_signal or cross_macd_zero) + macd_above_signal = (curr_macd > curr_signal) + + sma_condition = (curr_sma_short is not None and curr_sma_long is not None and curr_sma_short > curr_sma_long) + cross_sma = (prev_sma_short is not None and prev_sma_long is not None and prev_sma_short < prev_sma_long and curr_sma_short is not None and curr_sma_long is not None and curr_sma_short > curr_sma_long) + + adx_ok = (curr_adx is not None and curr_adx > adx_threshold) + cross_adx = (prev_adx is not None and curr_adx is not None and prev_adx <= adx_threshold and curr_adx > adx_threshold) + + matches = [] + if macd_cross_ok and sma_condition: + 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") + + if matches: + result["summary"].append(f"매수 신호발생: {symbol} -> {', '.join(matches)}") + text = f"매수 신호발생: {symbol} -> {', '.join(matches)}\n가격: {close:.2f}\n" + result["telegram"] = text + + amount = cfg.config.get("auto_trade", {}).get("buy_amount", 0) if cfg else 0 + trade_recorded = False + + if dry_run: + trade = make_trade_record(symbol, "buy", amount, True, price=close, status="simulated") + record_trade(trade, "trades.json") + trade_recorded = True + elif cfg is not None and cfg.trading_mode == "auto_trade": + auto_trade_cfg = cfg.config.get("auto_trade", {}) + buy_enabled = auto_trade_cfg.get("buy_enabled", False) + buy_amount = auto_trade_cfg.get("buy_amount", 0) + allowed_symbols = auto_trade_cfg.get("allowed_symbols", []) + + can_auto_buy = buy_enabled and buy_amount > 0 + if allowed_symbols and symbol not in allowed_symbols: + can_auto_buy = False + + if can_auto_buy: + logger.info("[%s] 자동 매수 조건 충족: 매수 주문 시작 (금액: %d)", symbol, buy_amount) + from .order import execute_buy_order_with_confirmation + + buy_result = execute_buy_order_with_confirmation(symbol=symbol, amount=buy_amount, config=cfg.config, telegram_token=telegram_token, telegram_chat_id=telegram_chat_id, account_number=cfg.kiwoom_account_number, dry_run=False, parse_mode=cfg.telegram_parse_mode) + result["buy_order"] = buy_result + + if buy_result.get("status") == "placed": + trade_recorded = True + from .holdings import add_new_holding + quantity = buy_result.get("quantity", 0) + add_new_holding(symbol, close, quantity, time.time(), "holdings.json") + + trade = make_trade_record(symbol, "buy", buy_amount, False, price=close, status="filled") + record_trade(trade, "trades.json") + + if not trade_recorded and not dry_run: + trade = make_trade_record(symbol, "buy", amount, False, price=close, status="notified") + record_trade(trade, "trades.json") + + return result + except Exception as e: + logger.exception("심볼 처리 중 오류: %s -> %s", symbol, e) + result["error"] = str(e) + result["summary"].append(f"심볼 처리 중 오류: {symbol} -> {e}") + return result + +def check_sell_conditions( + holdings: Dict[str, Dict[str, Any]], + cfg: RuntimeConfig, + config: Dict[str, Any] | None = None, + check_type: str = CheckType.ALL +) -> Tuple[List[Dict[str, Any]], int]: + """보유 종목의 매도 조건을 확인. + + Args: + holdings: 보유 종목 딕셔너리 + cfg: 런타임 설정 + config: 설정 딕셔너리 + check_type: 체크 타입 (CheckType.STOP_LOSS, CheckType.PROFIT_TAKING, CheckType.ALL) + + Returns: + (매도 결과 리스트, 매도 신호 개수) + """ + if config is None and cfg is not None and hasattr(cfg, "config"): + config = cfg.config + results = [] + + telegram_token = cfg.telegram_bot_token + telegram_chat_id = cfg.telegram_chat_id + dry_run = cfg.dry_run + account_number = cfg.kiwoom_account_number + trading_mode = cfg.trading_mode + + if not holdings: + logger.info("보유 정보가 없음 - 매도 조건 검사 건너뜀") + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매도 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=cfg.telegram_parse_mode or "HTML") + return [], 0 + + sell_signal_count = 0 + from src.order import execute_sell_order_with_confirmation + 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, + check_type + ) + + logger.info("[%s] 매도 조건 검사 - 현재가: %.2f, 매수가: %.2f, 최고가: %.2f, 수익률: %.2f%%, 최고점대비: %.2f%%", symbol, current_price, buy_price, max_price, sell_result["profit_rate"], sell_result["max_drawdown"]) + logger.info("[%s] 매도 상태: %s (비율: %.0f%%), 사유: %s", symbol, sell_result["status"], sell_result["sell_ratio"] * 100, ", ".join(sell_result["reasons"])) + result = {"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) + + 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.json") + logger.info("[%s] partial_sell_done 플래그 설정 완료 (dry_run)", symbol) + + if sell_result["sell_ratio"] > 0: + sell_signal_count += 1 + if ((cfg.trading_mode == "auto_trade" and dry_run) or cfg.trading_mode != "auto_trade"): + 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") + + 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 = int(total_amount * sell_ratio) + + if amount_to_sell > 0: + logger.info("[%s] 자동 매도 조건 충족: 매도 주문 시작 (총 수량: %d, 매도 비율: %.0f%%, 주문 수량: %d)", symbol, total_amount, sell_ratio * 100, amount_to_sell) + sell_order_result = execute_sell_order_with_confirmation( + symbol=symbol, + quantity=amount_to_sell, + config=config, + telegram_token=telegram_token, + telegram_chat_id=telegram_chat_id, + account_number=account_number, + dry_run=dry_run, + parse_mode=cfg.telegram_parse_mode or "HTML" + ) + + if sell_order_result and sell_result.get("set_partial_sell_done"): + if sell_order_result.get("status") == "placed": + from .holdings import set_holding_field + if set_holding_field(symbol, "partial_sell_done", True, holdings_file="holdings.json"): + logger.info("[%s] 부분 매도(1회성) 완료, partial_sell_done 플래그를 True로 업데이트합니다.", symbol) + else: + logger.error("[%s] partial_sell_done 플래그 업데이트에 실패했습니다.", symbol) + except Exception as e: + logger.exception("매도 조건 확인 중 오류 (%s): %s", symbol, e) + if telegram_token and telegram_chat_id and not any(r["sell_ratio"] > 0 for r in results): + send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매도 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=cfg.telegram_parse_mode or "HTML") + return results, sell_signal_count \ No newline at end of file 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_evaluate_sell_conditions.py b/src/tests/test_evaluate_sell_conditions.py new file mode 100644 index 0000000..ad617b5 --- /dev/null +++ b/src/tests/test_evaluate_sell_conditions.py @@ -0,0 +1,81 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +import time +import pytest +from src.signals import evaluate_sell_conditions + + +@pytest.fixture +def config(): + return { + "loss_threshold": -5.0, + "profit_threshold_1": 10.0, + "profit_threshold_2": 30.0, + "drawdown_1": 5.0, + "drawdown_2": 15.0, + } + + +def test_condition1_full_stop_loss(config): + # current price down 6% from buy -> full sell (condition1) + buy = 100.0 + curr = 94.0 # -6% + maxp = 100.0 + res = evaluate_sell_conditions(curr, buy, maxp, config) + assert res["status"] in ("stop_loss", "손절", "loss_cut") or res["sell_ratio"] == 1.0 + + +def test_condition2_stop_on_drawdown_small_profit(config): + # profit <= 10% and drawdown >= drawdown_1 (5%) -> full sell + buy = 100.0 + maxp = 105.0 + curr = 100.0 # profit 0%, drawdown from max -4.76% (not enough) + # adjust to make drawdown >5 + maxp = 110.0 + curr = 104.0 # profit 4%, drawdown ~5.45% -> full sell + res = evaluate_sell_conditions(curr, buy, maxp, config) + assert res["sell_ratio"] == 1.0 + + +def test_condition3_partial_take_profit(config): + # profit between 10% and 30% -> partial sell (50%) + buy = 100.0 + maxp = 115.0 + curr = 112.0 # profit 12% -> partial + res = evaluate_sell_conditions(curr, buy, maxp, config) + assert res["sell_ratio"] == 0.5 + + +def test_condition4_full_take_if_drawdown_in_10_30(config): + # profit 20% and drawdown from max >= drawdown_1 (5%) -> full sell + buy = 100.0 + maxp = 130.0 + curr = 120.0 # profit 20%, drawdown from max ~7.69% -> full + res = evaluate_sell_conditions(curr, buy, maxp, config) + assert res["sell_ratio"] == 1.0 + + +def test_condition5_high_profit_drawdown(config): + # profit >=30% and drawdown >=drawdown_2 (15%) -> full sell + buy = 100.0 + maxp = 150.0 + curr = 125.0 # profit 25% (not enough) -> adjust + curr = 140.0 # profit 40%, drawdown from max ~6.66% -> not full unless drawdown >=15 + # make drawdown >=15 + maxp = 170.0 + curr = 145.0 # profit 45%, drawdown ~14.7% (just below) + maxp = 180.0 + curr = 145.0 # drawdown 19.44% -> full + res = evaluate_sell_conditions(curr, buy, maxp, config) + assert res["sell_ratio"] == 1.0 + + +def test_condition6_hold(config): + # profit >=30% and drawdown less than drawdown_2 -> hold + buy = 100.0 + maxp = 150.0 + curr = 140.0 # profit 40%, drawdown ~6.66% -> hold + res = evaluate_sell_conditions(curr, buy, maxp, config) + 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..0224f83 --- /dev/null +++ b/src/tests/test_helpers.py @@ -0,0 +1,56 @@ +"""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, candle_count: 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, candle_count, 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..c4c208c --- /dev/null +++ b/src/tests/test_main.py @@ -0,0 +1,67 @@ +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 + idx = pd.date_range(end=pd.Timestamp.now(), periods=4, freq="h") + df = pd.DataFrame({"close": [100, 110, 120, 140]}, index=idx) + + # Monkeypatch at the point of use: src.signals imports from indicators + from src import signals + + # Patch fetch_ohlcv and compute_macd_hist in signals module + monkeypatch.setattr(signals, "fetch_ohlcv", lambda symbol, timeframe, candle_count=200, log_buffer=None: df) + # Return histogram with at least 2 non-NA values and matching df index + monkeypatch.setattr(signals, "compute_macd_hist", lambda close_series, log_buffer=None: pd.Series([0.0, 0.5, 1.0, 5.0], index=df.index)) + + # Monkeypatch pandas_ta.macd to raise exception so histogram fallback path is used + def fake_macd_fail(*args, **kwargs): + raise RuntimeError("force histogram fallback") + monkeypatch.setattr(signals.ta, "macd", fake_macd_fail) + + # 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", candle_count=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..42b5018 --- /dev/null +++ b/src/threading_utils.py @@ -0,0 +1,138 @@ +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], timeframe: str | None = None, indicator_timeframe: str | None = None, candle_count: int | None = None, telegram_token: str | None = None, telegram_chat_id: str | None = None, dry_run: bool | None = None, symbol_delay: float | None = None, parse_mode: str | None = None, aggregate_enabled: bool = False, cfg: RuntimeConfig | None = None): + if cfg is not None: + timeframe = timeframe or cfg.timeframe + indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe + candle_count = cfg.candle_count if (candle_count is None) else candle_count + telegram_token = telegram_token or cfg.telegram_bot_token + telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id + dry_run = cfg.dry_run if (dry_run is None) else dry_run + symbol_delay = cfg.symbol_delay if (symbol_delay is None) else symbol_delay + parse_mode = parse_mode or cfg.telegram_parse_mode + logger.info("순차 처리 시작 (심볼 수=%d)", len(symbols)) + alerts = [] + buy_signal_count = 0 + for i, sym in enumerate(symbols): + try: + res = process_symbol(sym, timeframe, candle_count, telegram_token, telegram_chat_id, dry_run, indicators=None, indicator_timeframe=indicator_timeframe, cfg=cfg) + for line in res.get("summary", []): + logger.info(line) + if res.get("telegram"): + buy_signal_count += 1 + if dry_run: + logger.info("[dry-run] 알림 내용:\n%s", res["telegram"]) + else: + # dry_run이 아닐 때는 콘솔에 메시지 출력하지 않음 + pass + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False, parse_mode=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 symbol_delay is not None: + logger.debug("다음 심볼까지 %.2f초 대기", symbol_delay) + time.sleep(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 dry_run: + logger.info("[dry-run] 알림 요약:\n%s", summary_text) + else: + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, summary_text, add_thread_prefix=False, parse_mode=parse_mode) + else: + logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가") + # 매수 조건이 하나도 충족되지 않은 경우 알림 전송 + if telegram_token and telegram_chat_id and not any(a.get("text") for a in alerts): + send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=parse_mode) + return buy_signal_count + + +def run_with_threads(symbols: List[str], timeframe: str | None = None, indicator_timeframe: str | None = None, candle_count: int | None = None, telegram_token: str | None = None, telegram_chat_id: str | None = None, dry_run: bool | None = None, symbol_delay: float | None = None, max_threads: int | None = None, parse_mode: str | None = None, aggregate_enabled: bool = False, cfg: RuntimeConfig | None = None): + if cfg is not None: + timeframe = timeframe or cfg.timeframe + indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe + candle_count = cfg.candle_count if (candle_count is None) else candle_count + telegram_token = telegram_token or cfg.telegram_bot_token + telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id + dry_run = cfg.dry_run if (dry_run is None) else dry_run + symbol_delay = cfg.symbol_delay if (symbol_delay is None) else symbol_delay + max_threads = cfg.max_threads if (max_threads is None) else max_threads + parse_mode = parse_mode or cfg.telegram_parse_mode + logger.info("병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)", len(symbols), max_threads or 0, symbol_delay or 0.0) + semaphore = threading.Semaphore(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 symbol_delay is not None and elapsed < symbol_delay: + sleep_time = symbol_delay - elapsed + logger.debug("[%s] 스로틀 대기: %.2f초", symbol, sleep_time) + time.sleep(sleep_time) + last_request_time[0] = time.time() + res = process_symbol(symbol, timeframe, candle_count, telegram_token, telegram_chat_id, dry_run, indicators=None, indicator_timeframe=indicator_timeframe, 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 dry_run: + logger.info("[dry-run] 알림 내용:\n%s", res["telegram"]) + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False, parse_mode=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 dry_run: + logger.info("[dry-run] 알림 요약:\n%s", summary_text) + else: + if telegram_token and telegram_chat_id: + send_telegram(telegram_token, telegram_chat_id, summary_text, add_thread_prefix=False, parse_mode=parse_mode) + else: + logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가") + # 매수 조건이 하나도 충족되지 않은 경우 알림 전송 + if telegram_token and telegram_chat_id and not any(a.get("text") for a in alerts): + send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=parse_mode) + logger.info("병렬 처리 완료") + return buy_signal_count