Compare commits
7 Commits
ab5c963803
...
976c53ed66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976c53ed66 | ||
|
|
872163a3d1 | ||
|
|
9944b55f94 | ||
|
|
526003c979 | ||
|
|
4f9e2c44c7 | ||
|
|
19a35e1009 | ||
|
|
7f1921441b |
437
STRATEGY.md
437
STRATEGY.md
@@ -1,309 +1,236 @@
|
|||||||
# Volume Lead 전략 가이드
|
# upbit-trader 전략 가이드
|
||||||
|
|
||||||
## 전략 개요
|
## 시스템 개요
|
||||||
|
|
||||||
**거래량 선행(Volume Lead) 매집 전략** — 가격이 횡보하는 중 거래량 급증이 발생하면
|
| 데몬 | 전략 | 상태 |
|
||||||
매집 신호로 기록하고, 이후 일정 수준 이상 상승 시 진입하는 선진입 전략.
|
|------|------|------|
|
||||||
|
| `tick-trader` | WebSocket 20초봉 + LLM 매수 + 트레일링 청산 | **운용 중** |
|
||||||
> 핵심 아이디어: 대형 매수자는 가격을 올리지 않고 조용히 매집한다.
|
| `upbit-trader` | 10분봉 Volume Lead 매집 전략 | 중지 (2026-03-06~) |
|
||||||
> 거래량이 먼저 급증하고, 가격 상승은 그 뒤에 따라온다.
|
|
||||||
|
|
||||||
**캔들 단위: 10분봉** (Upbit `minute10` API 직접 사용 — 리샘플링 없음)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 진입 조건 (2단계)
|
## 1. tick-trader (WebSocket 20초봉)
|
||||||
|
|
||||||
### 1단계: 매집 신호 감지
|
### 1.1 아키텍처
|
||||||
|
|
||||||
다음 두 조건 동시 충족 시 `signal_price` + `vol_ratio` 기록:
|
```
|
||||||
|
WebSocket (Upbit trade tick)
|
||||||
|
-> 20초봉 집계 (on_tick -> finalize_bars)
|
||||||
|
-> 시그널 감지 (양봉 + VOL >= 5x + 사전 필터 3종)
|
||||||
|
-> LLM 매수 판단 (get_entry_price)
|
||||||
|
-> 지정가 매수 (현재가)
|
||||||
|
-> 트레일링 스탑 / 손절 / 타임아웃 청산
|
||||||
|
```
|
||||||
|
|
||||||
| 조건 | 파라미터 | 기본값 |
|
### 1.2 감시 종목 (10개)
|
||||||
|------|----------|--------|
|
|
||||||
| QN봉(120분) 이전 종가 대비 가격 변동 < N% (횡보) | `PRICE_QUIET_PCT` | 2.0% |
|
|
||||||
| 직전 완성 10분봉 거래량 ≥ 로컬 LV봉(280분=28봉) 평균 × M배 | `VOL_THRESH_*` | 5.0x / 6.0x |
|
|
||||||
|
|
||||||
- 신호 발생 시 텔레그램 🔍 알림 발송
|
```
|
||||||
- `SIGNAL_TIMEOUT_MIN`(480분=8h) 초과 시 신호 초기화
|
ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR
|
||||||
- **신호불사**: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정, 만료까지 대기)
|
```
|
||||||
- **vol 갱신**: 더 강한 vol_ratio가 오면 신호가(sig_p)와 만료 시간 갱신
|
|
||||||
|
|
||||||
> **거래량 기준봉**: `df10["volume"].iloc[-2]` (직전 완성봉) vs 이전 28봉 평균
|
### 1.3 진입 조건
|
||||||
> **횡보 기준봉**: `df10["close"].iloc[-(QUIET_CANDLES+1)]` = 12봉(120분) 이전 종가
|
|
||||||
|
|
||||||
### 2단계: 추세 확인 후 진입
|
**시그널 감지** -- 20초봉 확정 시 다음 조건 동시 충족:
|
||||||
|
|
||||||
신호가 대비 `TREND_AFTER_VOL`% 이상 상승 확인 시 매수.
|
| 순서 | 조건 | 파라미터 | 값 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| 1 | 양봉 (close > open) | -- | 필수 |
|
||||||
|
| 2 | 거래량 >= 이전 61봉 평균 x N배 (trimmed mean, 상위 10% 제거) | `VOL_MIN` | 5.0x |
|
||||||
|
| 3 | 20초봉 거래대금 >= 하한 | `VOL_KRW_MIN` | 5,000,000원 |
|
||||||
|
| 4 | 횡보 필터: 최근 15봉 변동폭 >= 0.3% | `SPREAD_MIN` | 0.3% |
|
||||||
|
| 5 | 고점 필터: 30분 구간 내 90%+ 위치 & 변동 1%+ 아닐 것 | `HIGHPOS` | 90% / 1.0% |
|
||||||
|
| 6 | 연속 양봉 필터: 직전 2봉 이상 연속 양봉 | -- | >= 2 |
|
||||||
|
|
||||||
| 파라미터 | 기본값 | 설명 |
|
**LLM 매수 판단** -- 사전 필터 통과 후 LLM에게 매수 여부 위임:
|
||||||
|----------|--------|------|
|
|
||||||
| `TREND_AFTER_VOL` | 4.8% | 신호가 대비 추가 상승 진입 임계값 |
|
|
||||||
|
|
||||||
### 신호 감시 스레드 (Fast Poll)
|
- LLM이 DB Tool로 시장 데이터 조회 후 `buy` / `skip` 판단
|
||||||
|
- `buy` 시 현재가로 지정가 매수 (LLM은 가격 결정 안 함)
|
||||||
|
- `skip` 시 텔레그램 알림 + 사유 기록
|
||||||
|
- 과거 연패/승률은 고려하지 않도록 프롬프팅 (get_trade_history 제거)
|
||||||
|
|
||||||
신호 감지 후 전체 스캔 60초를 기다리지 않고 해당 종목만 빠르게 폴링.
|
**중복/한도 방지**:
|
||||||
|
- 이미 보유(`positions`) 또는 매수대기(`pending_buys`) 종목은 스킵
|
||||||
|
- LLM 호출 전/후 포지션 한도(`MAX_POS`) 이중 체크
|
||||||
|
- 예산 체크: MAX_BUDGET - (보유 투자금 + 미체결 투자금)
|
||||||
|
- 미체결 180초 초과 시 자동 취소
|
||||||
|
|
||||||
| 파라미터 | 기본값 | 설명 |
|
### 1.4 청산: 트레일링 스탑
|
||||||
|----------|--------|------|
|
|
||||||
| `SCAN_INTERVAL` | 60초 | 전체 시장 스캔 주기 |
|
LLM 매도는 제거됨. 규칙 기반 트레일링 스탑으로 청산.
|
||||||
| `SIGNAL_POLL_INTERVAL` | 15초 | 신호 종목 집중 감시 주기 |
|
|
||||||
|
| 조건 | 파라미터 | 값 | 설명 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| 트레일링 스탑 | `TRAIL_PCT` | -1.5% | 고점 대비 하락 시 시장가 청산 |
|
||||||
|
| 최소 수익 | `MIN_PROFIT_PCT` | +0.5% | 트레일 발동 최소 수익률 |
|
||||||
|
| 손절 | `STOP_LOSS_PCT` | -2.0% | 진입가 대비 -2% 시 시장가 청산 |
|
||||||
|
| 타임아웃 | `TIMEOUT_SECS` | 14,400초 (4h) | 경과 시 시장가 청산 |
|
||||||
|
|
||||||
|
- 실시간 tick마다 peak 갱신 + 손절/트레일 체크 (`update_positions`)
|
||||||
|
- 20초봉 확정 시에도 체크 (`check_filled_positions`)
|
||||||
|
|
||||||
|
### 1.5 LLM 어드바이저
|
||||||
|
|
||||||
|
**모델**: Google Gemini 2.5 Flash (OpenRouter API)
|
||||||
|
|
||||||
|
**비용**: ~5원/일 (~150원/월) -- 매도 LLM 제거 + 사전 필터로 대폭 절감
|
||||||
|
|
||||||
|
**DB Tool 4개** (매수 판단용):
|
||||||
|
|
||||||
|
| Tool | 데이터 소스 | 용도 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `get_price_ticks` | Oracle `price_tick` | 최근 N분 가격 틱 (단기 추세) |
|
||||||
|
| `get_ohlcv` | Oracle `backtest_ohlcv` | 1분봉 OHLCV (지지/저항) |
|
||||||
|
| `get_ticker_context` | Oracle `ticker_context` | 종목 평판 (가격 변동, 뉴스) |
|
||||||
|
| `get_btc_trend` | Oracle `backtest_ohlcv` | BTC 4시간봉 추세 |
|
||||||
|
|
||||||
|
**최적화**:
|
||||||
|
- Tool call 중복 제거: 동일 tool+args 호출 시 캐시된 결과 반환
|
||||||
|
- max_rounds=5: 최대 5라운드 tool calling 후 강제 응답
|
||||||
|
|
||||||
|
### 1.6 재시작 복구
|
||||||
|
|
||||||
|
PM2 재시작 시 `restore_positions()`:
|
||||||
|
1. Upbit `get_balances()`로 보유 종목 조회 (balance + locked)
|
||||||
|
2. 포지션 복구 (트레일링 스탑 모드)
|
||||||
|
3. 미체결 매수 주문도 `pending_buys`에 복구
|
||||||
|
4. `entry_ts` 백데이팅으로 즉시 활성화
|
||||||
|
|
||||||
|
### 1.7 가격 표시
|
||||||
|
|
||||||
|
`fp()` 헬퍼로 가격대별 소수점 자동 조정:
|
||||||
|
- >= 100원: 정수 (예: 106,177,000)
|
||||||
|
- >= 10원: 소수 1자리 (예: 47.5)
|
||||||
|
- < 10원: 소수 2자리 (예: 0.85)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## F&G 기반 거래량 임계값 + 진입 차단
|
## 2. upbit-trader (10분봉 Volume Lead) [중지됨]
|
||||||
|
|
||||||
**alternative.me API** 기반 일일 공포탐욕지수(0~100) 조회. F&G 구간에 따라
|
> 중지 사유: tick-trader와 동일 계좌에서 동시 운용 시 예산 초과 문제
|
||||||
거래량 임계값을 동적으로 조정하고, 탐욕 구간은 진입 전면 차단.
|
|
||||||
|
|
||||||
| F&G 구간 | 레이블 | 거래량 임계값 | 진입 여부 |
|
- 10분봉 Volume Lead 매집 전략
|
||||||
|----------|--------|-------------|-----------|
|
- 횡보 중 거래량 급증 -> 신호 기록 -> +4.8% 상승 확인 후 진입
|
||||||
| 0 ~ 40 | 극공포 / 공포 | **6.0x** (`VOL_THRESH_FEAR`) | ✅ 허용 |
|
- F&G 필터: <=40->6x / 41~50->5x / >50->차단
|
||||||
| 41 ~ 50 | 약공포 / 중립 | **5.0x** (`VOL_THRESH_NORMAL`) | ✅ 허용 |
|
- ATR 트레일링 스탑 + 타임스탑(8h)
|
||||||
| 51 ~ 100 | 탐욕 / 극탐욕 | — | ❌ **전면 차단** |
|
- WF 필터: Oracle DB 영속 저장
|
||||||
|
|
||||||
- `FNG_MAX_ENTRY=50` — 초과 시 스캔 전체 스킵
|
|
||||||
- `FNG_FEAR_THRESHOLD=40` — 이하 시 FEAR 임계값(6.0x) 적용
|
|
||||||
- API 하루 1회 KST 09:00 업데이트, 캐시 TTL 24시간
|
|
||||||
- API 실패 시 폴백: 50 (중립, 진입 허용)
|
|
||||||
|
|
||||||
**탐욕 구간 차단 이유** (1년 백테스트 검증):
|
|
||||||
|
|
||||||
| F&G 구간 | 승률 | 평균 손익 | 누적 수익 |
|
|
||||||
|----------|------|---------|---------|
|
|
||||||
| 극공포(0~25) | 57% | +0.71% | 수익 |
|
|
||||||
| 공포(26~40) | 53% | +0.45% | 수익 |
|
|
||||||
| 중립(41~50) | 45% | +0.20% | 수익 |
|
|
||||||
| 탐욕(56~75) | **28%** | **-0.33%** | **손실** |
|
|
||||||
| 극탐욕(76+) | 25% | -0.50% | 손실 |
|
|
||||||
|
|
||||||
탐욕 구간 손실 원인:
|
|
||||||
1. 이미 많이 오른 상태에서 진입 (QUIET 조건 충족 어려움)
|
|
||||||
2. vol spike = 세력 차익실현 매물인 경우 多
|
|
||||||
3. 진입 후 추가 상승 여력 부족
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 관찰 알림 (Watch Alert)
|
## 3. 공통 인프라
|
||||||
|
|
||||||
신호 임계값(6x/5x)에 못 미치지만 근접한 종목을 텔레그램 👀 알림으로 통보.
|
### 3.1 데이터 수집 데몬
|
||||||
실제 매수 로직에는 영향 없음.
|
|
||||||
|
|
||||||
| 파라미터 | 기본값 | 설명 |
|
| 데몬 | 주기 | 역할 |
|
||||||
|----------|--------|------|
|
|------|------|------|
|
||||||
| `WATCH_VOL_THRESH` | 4.0x | 관찰 시작 임계값 |
|
| `tick-collector` | 30초 | `price_tick` 30초봉 + `backtest_ohlcv` 1분봉 Oracle 저장 |
|
||||||
| `WATCH_COOLDOWN_MIN` | 30분 | 같은 종목 재알림 최소 간격 |
|
| `context-collector` | 1시간 | 종목별 `ticker_context` (가격 통계 + SearXNG 뉴스) |
|
||||||
| `WATCH_VOL_JUMP` | 0.5x | 쿨다운 무시 vol 상승폭 |
|
|
||||||
|
|
||||||
- 조건: `WATCH_VOL_THRESH ≤ vol_r < vth` AND `chg < PRICE_QUIET_PCT`
|
### 3.2 기술 스택
|
||||||
- 30분 쿨다운 (vol_r이 0.5x 이상 뛰면 즉시 재알림)
|
|
||||||
|
| 구성 | 기술 |
|
||||||
|
|------|------|
|
||||||
|
| 거래소 | Upbit API (REST + WebSocket) |
|
||||||
|
| DB | Oracle ADB (price_tick, backtest_ohlcv, ticker_context, trade_log, wf_state, position_sync) |
|
||||||
|
| LLM | Google Gemini 2.5 Flash via OpenRouter |
|
||||||
|
| 알림 | Telegram Bot API |
|
||||||
|
| 뉴스 | SearXNG (self-hosted) |
|
||||||
|
| 프로세스 | PM2 (tick-trader, tick-collector, context-collector) |
|
||||||
|
|
||||||
|
### 3.3 예산 관리
|
||||||
|
|
||||||
|
| 파라미터 | 값 | 설명 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `MAX_BUDGET` | 1,000,000원 | 총 운용 예산 |
|
||||||
|
| `MAX_POSITIONS` | 5 | 최대 동시 보유 종목 |
|
||||||
|
| 종목당 예산 | 200,000원 | `MAX_BUDGET / MAX_POSITIONS` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 청산 조건
|
## 4. 프로젝트 구조
|
||||||
|
|
||||||
### 트레일링 스탑 (ATR 기반)
|
### 프로덕션
|
||||||
|
|
||||||
- 10분봉 최근 ATR_N봉(7봉=70분) 평균 진폭 계산
|
| 파일 | 역할 |
|
||||||
- `stop_pct = 평균진폭 × ATR_MULT(1.5)` → 동적 손절폭
|
|------|------|
|
||||||
- 최소 `ATR_MIN=1.0%` / 최대 `ATR_MAX=2.0%` 범위 내 자동 조정
|
| `daemons/tick_trader.py` | WebSocket 20초봉 트레이더 (LLM 매수 + 트레일링 청산) |
|
||||||
- 최고가(peak) 대비 `stop_pct` 이하 하락 시 즉시 청산
|
| `daemons/tick_collector.py` | price_tick + 1분봉 수집 |
|
||||||
|
| `daemons/context_collector.py` | 종목 컨텍스트 수집 (뉴스 + 가격 통계) |
|
||||||
|
| `core/llm_advisor.py` | OpenRouter LLM 매수 어드바이저 (tool calling) |
|
||||||
|
| `core/notify.py` | 텔레그램 알림 |
|
||||||
|
|
||||||
### 타임 스탑
|
### 설정
|
||||||
|
|
||||||
- 보유 `TS_N`봉(48봉=480분=8h) 경과 후 수익률 < `TIME_STOP_MIN_PCT`(3.0%)이면 청산
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `.env` | API 키, 전략 파라미터, DB 설정 |
|
||||||
|
| `ecosystem.config.js` | PM2 프로세스 설정 |
|
||||||
|
|
||||||
|
### 백테스트
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `backtest_march.py` | 3월 1분봉 시뮬레이션 (Oracle DB 데이터) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 리스크 관리
|
## 5. 운용 설정 (.env)
|
||||||
|
|
||||||
### Walk-Forward (WF) 필터
|
|
||||||
|
|
||||||
| 파라미터 | 기본값 | 설명 |
|
|
||||||
|----------|--------|------|
|
|
||||||
| `WF_WINDOW` | 5 | 직전 N건 이력 윈도우 |
|
|
||||||
| `WF_MIN_WIN_RATE` | 0.40 | 최소 승률 임계값 (40%) |
|
|
||||||
| `WF_SHADOW_WINS` | 2 | 차단 해제 조건 (가상 N연승) |
|
|
||||||
|
|
||||||
- 직전 5건 승률 < 40% → 해당 종목 진입 차단 + 가상(shadow) 포지션으로 재활 추적
|
|
||||||
- 가상 2연승 달성 시 자동 복귀
|
|
||||||
- **WF 차단 상태는 Oracle DB(`wf_state` 테이블)에 영속 저장** → 재시작 후에도 복원
|
|
||||||
|
|
||||||
### 재매수 차단
|
|
||||||
|
|
||||||
- 직전 거래가 손실이었을 경우: 현재가 < 직전매도가 × 1.01 이면 재진입 차단
|
|
||||||
- 직전 거래가 수익이었을 경우: 필터 스킵 (추가 상승 재진입 허용)
|
|
||||||
|
|
||||||
### 예산 관리 (복리)
|
|
||||||
|
|
||||||
- 수익/손실 시 운용예산 실시간 반영 (복리)
|
|
||||||
- 하한선: 초기예산의 30% (기본: 4,500,000원)
|
|
||||||
- 포지션당 크기: `운용예산 / MAX_POSITIONS`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 운용 설정 (.env)
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 핵심 전략 (10분봉 직접 감지)
|
# 총 운용 예산 / 최대 동시 보유 종목
|
||||||
LOCAL_VOL_CANDLES=28 # 로컬 vol 평균 구간 (28봉=280분)
|
MAX_BUDGET=1000000
|
||||||
QUIET_CANDLES=12 # 횡보 체크 구간 (12봉=120분)
|
MAX_POSITIONS=5
|
||||||
PRICE_QUIET_PCT=2.0 # 횡보 기준 (%)
|
|
||||||
TREND_AFTER_VOL=4.8 # 진입 임계값 (신호가 대비 %)
|
|
||||||
SIGNAL_TIMEOUT_MIN=480 # 신호 유효 시간 (분=8h)
|
|
||||||
|
|
||||||
# F&G 기반 거래량 임계값
|
# LLM (OpenRouter)
|
||||||
VOL_THRESH_NORMAL=5.0 # 중립 구간 (F&G 41~50)
|
OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
VOL_THRESH_FEAR=6.0 # 공포/극공포 (F&G ≤ 40)
|
LLM_MODEL=google/gemini-2.5-flash
|
||||||
FNG_FEAR_THRESHOLD=40 # 공포 기준 (이하 → FEAR 임계값)
|
|
||||||
FNG_MAX_ENTRY=50 # 진입 허용 최대 F&G (초과 → 차단)
|
|
||||||
|
|
||||||
# 관찰 알림
|
# Oracle ADB
|
||||||
WATCH_VOL_THRESH=4.0 # 관찰 시작 임계값
|
ORACLE_USER=admin
|
||||||
WATCH_COOLDOWN_MIN=30 # 재알림 최소 간격 (분)
|
ORACLE_PASSWORD=...
|
||||||
WATCH_VOL_JUMP=0.5 # 쿨다운 무시 vol 상승폭
|
ORACLE_DSN=...
|
||||||
|
ORACLE_WALLET=...
|
||||||
|
|
||||||
# 청산
|
# Telegram
|
||||||
TIME_STOP_MIN_PCT=3.0 # 타임스탑 최소 수익률
|
TELEGRAM_TRADE_TOKEN=...
|
||||||
# TS_N=48봉(480분), ATR_MULT=1.5, ATR_MIN=1%, ATR_MAX=2% (코드 내 고정)
|
TELEGRAM_CHAT_ID=...
|
||||||
|
|
||||||
# 포트폴리오
|
|
||||||
MAX_BUDGET=15000000 # 초기 운용 예산
|
|
||||||
MAX_POSITIONS=3 # 최대 동시 보유 종목
|
|
||||||
|
|
||||||
# 감시 주기
|
|
||||||
SIGNAL_POLL_INTERVAL=15 # 신호 종목 빠른 감시 (초)
|
|
||||||
|
|
||||||
# WF 필터
|
|
||||||
WF_WINDOW=5
|
|
||||||
WF_MIN_WIN_RATE=0.40
|
|
||||||
WF_SHADOW_WINS=2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 백테스트 결과 요약
|
## 6. 백테스트 결과
|
||||||
|
|
||||||
### A. 1년 — 10분봉 직접, FNG_MAX_ENTRY=50 (`tests/sim_10m_vol.py`)
|
### 3월 시뮬레이션 (1분봉, 연속양봉 필터 적용)
|
||||||
> 기간: 2025-03-03 ~ 2026-03-03 / 데이터: Upbit minute10 캐시 / 20종목
|
> 기간: 2026-03-01 ~ 2026-03-06 / 10종목 / MAX_POS=3
|
||||||
|
|
||||||
| 항목 | 필터 없음 | FNG_MAX_ENTRY=50 적용 |
|
| 항목 | 필터 전 | 연속양봉 >= 2 적용 |
|
||||||
|------|---------|---------------------|
|
|------|---------|-------------------|
|
||||||
| 수익률 | +6.80% | **+18.81%** |
|
| 시그널 | 112건 | 48건 (-57%) |
|
||||||
| 최대 낙폭 | -17.0% | **-14.3%** |
|
| 승률 | 38.4% | **52.1%** |
|
||||||
| 거래수 | 286건 | 218건 |
|
| 총 PNL | +11,530원 | **+17,868원** |
|
||||||
| 승률 | 42.0% | 46.3% |
|
| 평균 PNL | +0.13% | **+1.22%** |
|
||||||
|
| 손절 | 31건 | 12건 |
|
||||||
### B. 월별 성과 — 1년 10분봉 (`tests/sim_regime_1y.py`)
|
| 트레일 익절 | 13건 | 18건 |
|
||||||
> FNG_MAX_ENTRY=50, VOL_THRESH_FEAR=6.0, VOL_THRESH_NORMAL=5.0
|
|
||||||
|
|
||||||
| 월 | 거래 | 승률 | 월수익(KRW) | F&G 특징 |
|
|
||||||
|----|------|------|------------|---------|
|
|
||||||
| 2025-03 | 14건 | 57% | +447,000원 | 공포→중립 |
|
|
||||||
| 2025-04 | 5건 | 60% | +289,000원 | 중립 |
|
|
||||||
| 2025-05 | 11건 | 55% | +506,000원 | 공포 |
|
|
||||||
| 2025-06 | 4건 | 25% | -88,000원 | 탐욕(차단) |
|
|
||||||
| 2025-07 | 18건 | 44% | +311,000원 | 공포 |
|
|
||||||
| 2025-08 | 12건 | 50% | +370,000원 | 공포 |
|
|
||||||
| 2025-09 | 18건 | 44% | +250,000원 | 중립 |
|
|
||||||
| 2025-10 | 20건 | 50% | +430,000원 | 공포 |
|
|
||||||
| 2025-11 | 24건 | 54% | +698,000원 | 중립→탐욕(차단) |
|
|
||||||
| 2025-12 | 22건 | 41% | -180,000원 | 탐욕(차단효과) |
|
|
||||||
| 2026-01 | 31건 | 35% | -630,000원 | 극공포 |
|
|
||||||
| 2026-02 | 39건 | 51% | +1,817,000원 | 공포→중립 |
|
|
||||||
|
|
||||||
### C. THRESH 스윕 — 신호가 대비 진입 임계값 (`tests/sim_10m_vol.py` 내 스윕)
|
|
||||||
> FNG_MAX_ENTRY=50, VOL_THRESH_FEAR=6.0, 1년 10분봉 / 20종목
|
|
||||||
|
|
||||||
| THRESH | 진입 | 승률 | 평균손익 | 수익률 | 최대낙폭 |
|
|
||||||
|--------|------|------|---------|--------|---------|
|
|
||||||
| 0.0% | 3552건 | 26.6% | -0.33% | -70.0% | -70.4% |
|
|
||||||
| 1.0% | 2012건 | 25.6% | -0.35% | -70.0% | -70.1% |
|
|
||||||
| 2.0% | 1075건 | 27.9% | -0.27% | -62.1% | -62.9% |
|
|
||||||
| 3.0% | 660건 | 28.2% | -0.23% | -40.9% | -42.0% |
|
|
||||||
| 4.0% | 398건 | 29.6% | -0.14% | -17.2% | -22.7% |
|
|
||||||
| **4.8%** | **272건** | **36.4%** | **+0.09%** | **+7.6%** | **-12.3%** ← 현재 |
|
|
||||||
| **6.0%** | **180건** | **38.9%** | **+0.18%** | **+10.6%** | **-7.7%** ← 최적 |
|
|
||||||
| 8.0% | 100건 | 44.0% | +0.12% | +3.8% | -4.8% |
|
|
||||||
|
|
||||||
> vol spike 직후 진입(0~4%)은 가짜 펌프 위험으로 전부 손실.
|
|
||||||
> 추가 상승 확인(4.8%+)이 필수. 최적값은 6.0%이나 신호 포착 빈도와 트레이드오프.
|
|
||||||
|
|
||||||
### D. F&G 탐욕 차단 효과 — 1년 10분봉
|
|
||||||
|
|
||||||
| 구성 | 수익률 | 최대낙폭 | 거래수 |
|
|
||||||
|------|--------|---------|--------|
|
|
||||||
| 차단 없음 | +6.8% | -17.0% | 286건 |
|
|
||||||
| FNG_MAX_ENTRY=60 | +12.4% | -15.8% | 254건 |
|
|
||||||
| **FNG_MAX_ENTRY=50** | **+18.8%** | **-14.3%** | **218건** |
|
|
||||||
| FNG_MAX_ENTRY=45 | +15.1% | -13.8% | 195건 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 주요 파일
|
## 7. 변경 이력
|
||||||
|
|
||||||
**프로덕션 코어**
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| `core/strategy.py` | 진입 신호 로직 (10분봉 vol-lead + 신호불사 + 관찰알림) |
|
|
||||||
| `core/fng.py` | F&G 필터 (alternative.me API, 24h 캐시) |
|
|
||||||
| `core/monitor.py` | ATR 트레일링 스탑 + 타임스탑 |
|
|
||||||
| `core/trader.py` | 주문 실행 + 복리 예산 + WF 필터 |
|
|
||||||
| `core/notify.py` | 텔레그램 알림 (매수/매도/신호/관찰/상태) |
|
|
||||||
| `core/market.py` | 상위 거래량 종목 조회 |
|
|
||||||
| `daemon/runner.py` | 전체 스캔 루프 + 신호 종목 fast-poll 스레드 |
|
|
||||||
| `main.py` | 진입점 (스레드 시작 + pm2) |
|
|
||||||
|
|
||||||
**백테스트 / 분석 (`tests/`)**
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| `tests/collect_1y_data.py` | 1년치 10분봉 데이터 수집 + 캐시 저장 |
|
|
||||||
| `tests/refresh_cache.py` | 캐시 최신화 (최근 데이터 추가) |
|
|
||||||
| `tests/sim_10m_vol.py` | 1년 10분봉 복리 시뮬레이션 (현재 전략) |
|
|
||||||
| `tests/sim_current.py` | 특정 날짜 기준 당일/전일 시뮬 |
|
|
||||||
| `tests/sim_regime_1y.py` | 1년 월별 성과 분석 |
|
|
||||||
| `tests/sim_regime_sweep.py` | REGIME_N 파라미터 스윕 (레거시) |
|
|
||||||
| `tests/sim_vol_override.py` | VOL_THRESH / THRESH 파라미터 스윕 |
|
|
||||||
| `tests/sim_45m40.py` | 40분봉 복리 시뮬레이션 (레거시) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 시뮬레이션 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1년치 10분봉 데이터 수집 (최초 1회, ~13분 소요)
|
|
||||||
python tests/collect_1y_data.py
|
|
||||||
|
|
||||||
# 캐시 최신화 (기존 데이터에 최근분 추가)
|
|
||||||
python tests/refresh_cache.py
|
|
||||||
|
|
||||||
# 1년 복리 시뮬 — 현재 전략 기준
|
|
||||||
python tests/sim_10m_vol.py
|
|
||||||
|
|
||||||
# 특정 날짜 기준 시뮬 (당일 신호 확인용)
|
|
||||||
python tests/sim_current.py
|
|
||||||
|
|
||||||
# 1년 월별 성과 분석
|
|
||||||
python tests/sim_regime_1y.py
|
|
||||||
|
|
||||||
# THRESH / VOL_THRESH 파라미터 스윕
|
|
||||||
python tests/sim_vol_override.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 변경 이력
|
|
||||||
|
|
||||||
| 날짜 | 변경 내용 |
|
| 날짜 | 변경 내용 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| 2026-03-04 | **전략 전면 재작성** — 10분봉 직접 감지, 신호불사, vol 갱신 |
|
| 2026-03-06 | 연속 양봉 >= 2 필터 추가 (승률 38% -> 52%) |
|
||||||
| 2026-03-04 | F&G 3구간 시스템: ≤40→6x / 41~50→5x / >50→차단 (`FNG_MAX_ENTRY=50`) |
|
| 2026-03-06 | 예산 변경: 100K/3pos -> 1M/5pos (종목당 200K) |
|
||||||
| 2026-03-04 | 관찰 알림(Watch Alert) 추가: 4x~신호임계값 근접 시 텔레그램 👀 알림 |
|
| 2026-03-06 | LLM 매도 제거 -> 트레일링 스탑 전환 (TRAIL -1.5%, SL -2%, 4h timeout) |
|
||||||
| 2026-03-04 | 1년 10분봉 시뮬레이션 추가 (20종목, `data/sim1y_cache.pkl`) |
|
| 2026-03-06 | cascade 매도 제거 |
|
||||||
| 2026-03-04 | THRESH 스윕 결과: 현재 4.8% 유지, 최적값 6.0% 확인 |
|
| 2026-03-06 | 사전 필터 3종 추가 (횡보/고점/연속양봉) -> LLM 호출 ~57% 절감 |
|
||||||
| 2026-03-04 | runner.py: Bear레짐 차단 제거, FNG_MAX_ENTRY(>50) 차단으로 통일 |
|
| 2026-03-06 | 종목 30개 -> 10개 축소, BTC 제외 |
|
||||||
| 2026-03-03 | F&G 필터 최초 추가 (`FNG_MIN_ENTRY=41`) |
|
| 2026-03-06 | 현재가 매수 (LLM 가격 제안 제거) |
|
||||||
| 2026-03-03 | 속도 기반 조기 진입 추가 (현재 제거됨) |
|
| 2026-03-06 | LLM 매수 프롬프트: 연패 무시, get_trade_history 제거 |
|
||||||
| 2026-03-03 | 신호 종목 fast-poll 스레드 추가 (`SIGNAL_POLL_INTERVAL=15s`) |
|
| 2026-03-06 | VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초 |
|
||||||
| 2026-03-03 | 프로젝트 구조 정리: `tests/` `data/` `logs/` 폴더 분리 |
|
| 2026-03-06 | 예산 체크: MAX_BUDGET - 투자금 합계 방식 |
|
||||||
|
| 2026-03-06 | `_round_price` 호가 단위 수정: 10만~50만 구간 50->100원 |
|
||||||
|
| 2026-03-06 | 매도 주문 실패 시 자동 재시도 |
|
||||||
|
| 2026-03-06 | upbit-trader (보조 봇) 중지 |
|
||||||
|
| 2026-03-05 | `tick-trader` LLM-driven 매수/매도 전환, cascade fallback 구현 |
|
||||||
|
| 2026-03-05 | LLM 모델 Claude Haiku 4.5 -> Gemini 2.5 Flash (비용 7.5x 절감) |
|
||||||
|
| 2026-03-05 | `restore_positions()`: 잔고 기반 포지션 복구 |
|
||||||
|
| 2026-03-05 | 포지션 한도 초과 방지 3중 체크 |
|
||||||
|
|||||||
433
backtest_march.py
Normal file
433
backtest_march.py
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
"""3월 백테스트 — 현재 tick_trader 설정 기준 (1분봉 시뮬레이션)
|
||||||
|
|
||||||
|
20초봉 → 1분봉 환산:
|
||||||
|
VOL_LOOKBACK 61(20s) → 20(1min)
|
||||||
|
트레일/손절은 가격 기반이라 동일
|
||||||
|
타임아웃 4h = 240분
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ── 설정 (현재 tick_trader 기준) ──
|
||||||
|
TICKERS = [
|
||||||
|
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
|
||||||
|
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
||||||
|
]
|
||||||
|
|
||||||
|
VOL_LOOKBACK = 20 # 1분봉 기준
|
||||||
|
VOL_MIN = 5.0
|
||||||
|
VOL_KRW_MIN = 5_000_000 # 1분봉 거래대금 (20초봉 500만 × 3)
|
||||||
|
SPREAD_MIN = 0.3 # 횡보 필터: 5봉 변동폭 % (15봉/3)
|
||||||
|
HIGHPOS_BARS = 30 # 고점 필터: 30봉(30분)
|
||||||
|
HIGHPOS_THR = 0.9
|
||||||
|
HIGHPOS_MOVE = 1.0 # 구간 변동 1%+
|
||||||
|
|
||||||
|
TRAIL_PCT = 0.015
|
||||||
|
MIN_PROFIT_PCT = 0.005
|
||||||
|
STOP_LOSS_PCT = 0.02
|
||||||
|
TIMEOUT_BARS = 240 # 4시간
|
||||||
|
|
||||||
|
MAX_POS = 3
|
||||||
|
PER_POS = 33_333
|
||||||
|
FEE = 0.0005
|
||||||
|
|
||||||
|
|
||||||
|
def calc_vr(bars, idx):
|
||||||
|
start = max(0, idx - VOL_LOOKBACK)
|
||||||
|
end = max(0, idx - 1)
|
||||||
|
baseline = sorted(bars[i]['volume'] for i in range(start, end))
|
||||||
|
if not baseline:
|
||||||
|
return 0.0
|
||||||
|
trim = max(1, len(baseline) // 10)
|
||||||
|
trimmed = baseline[:len(baseline) - trim]
|
||||||
|
if not trimmed:
|
||||||
|
return 0.0
|
||||||
|
avg = sum(trimmed) / len(trimmed)
|
||||||
|
return bars[idx]['volume'] / avg if avg > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(ticker, start_date='2026-03-01', end_date='2026-03-07'):
|
||||||
|
"""Oracle backtest_ohlcv에서 1분봉 로드, 없으면 pyupbit 페이징."""
|
||||||
|
import os
|
||||||
|
bars = []
|
||||||
|
|
||||||
|
# Oracle DB 시도
|
||||||
|
try:
|
||||||
|
import oracledb
|
||||||
|
kwargs = dict(
|
||||||
|
user=os.environ['ORACLE_USER'],
|
||||||
|
password=os.environ['ORACLE_PASSWORD'],
|
||||||
|
dsn=os.environ['ORACLE_DSN'],
|
||||||
|
)
|
||||||
|
if w := os.environ.get('ORACLE_WALLET'):
|
||||||
|
kwargs['config_dir'] = w
|
||||||
|
conn = oracledb.connect(**kwargs)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT ts, open_p, high_p, low_p, close_p, volume_p
|
||||||
|
FROM backtest_ohlcv
|
||||||
|
WHERE ticker = :t AND interval_cd = 'minute1'
|
||||||
|
AND ts >= TO_TIMESTAMP(:s, 'YYYY-MM-DD')
|
||||||
|
AND ts < TO_TIMESTAMP(:e, 'YYYY-MM-DD')
|
||||||
|
ORDER BY ts
|
||||||
|
""", {'t': ticker, 's': start_date, 'e': end_date})
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
if rows:
|
||||||
|
for ts, o, h, l, c, v in rows:
|
||||||
|
bars.append({'ts': ts, 'open': float(o), 'high': float(h),
|
||||||
|
'low': float(l), 'close': float(c), 'volume': float(v)})
|
||||||
|
return bars
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: pyupbit 페이징
|
||||||
|
import pandas as pd
|
||||||
|
all_df = []
|
||||||
|
to = end_date + ' 23:59:59'
|
||||||
|
for _ in range(60):
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=200, to=to)
|
||||||
|
if df is None or df.empty:
|
||||||
|
break
|
||||||
|
all_df.append(df)
|
||||||
|
earliest = df.index[0]
|
||||||
|
if str(earliest)[:10] < start_date:
|
||||||
|
break
|
||||||
|
to = (earliest - timedelta(minutes=1)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
time.sleep(0.12)
|
||||||
|
|
||||||
|
if not all_df:
|
||||||
|
return []
|
||||||
|
|
||||||
|
merged = pd.concat(all_df).sort_index()
|
||||||
|
merged = merged[~merged.index.duplicated(keep='first')]
|
||||||
|
merged = merged[start_date:end_date]
|
||||||
|
|
||||||
|
for ts, row in merged.iterrows():
|
||||||
|
bars.append({'ts': ts, 'open': row['open'], 'high': row['high'],
|
||||||
|
'low': row['low'], 'close': row['close'], 'volume': row['volume']})
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest():
|
||||||
|
print("=" * 80)
|
||||||
|
print("3월 백테스트 (1분봉 시뮬레이션)")
|
||||||
|
print(f"종목: {len(TICKERS)}개 | VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M")
|
||||||
|
print(f"트레일: 고점-{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%) | 손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_BARS//60}h")
|
||||||
|
print(f"포지션: 최대 {MAX_POS} | 종목당 {PER_POS:,}원")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 데이터 로드
|
||||||
|
all_bars = {}
|
||||||
|
for t in TICKERS:
|
||||||
|
print(f" 로딩 {t}...", end=' ')
|
||||||
|
bars = load_data(t)
|
||||||
|
print(f"{len(bars)}봉")
|
||||||
|
all_bars[t] = bars
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# 모든 종목의 타임스탬프를 합쳐서 시간순 이벤트 생성
|
||||||
|
events = []
|
||||||
|
for t in TICKERS:
|
||||||
|
for i, b in enumerate(all_bars[t]):
|
||||||
|
events.append((b['ts'], t, i))
|
||||||
|
events.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
# 시뮬레이션
|
||||||
|
positions = {}
|
||||||
|
trades = []
|
||||||
|
signals_count = 0
|
||||||
|
skipped_spread = 0
|
||||||
|
skipped_highpos = 0
|
||||||
|
skipped_maxpos = 0
|
||||||
|
skipped_greens = 0
|
||||||
|
|
||||||
|
for ts, ticker, idx in events:
|
||||||
|
bar_list = all_bars[ticker]
|
||||||
|
b = bar_list[idx]
|
||||||
|
|
||||||
|
# ── 포지션 관리 (매 봉마다) ──
|
||||||
|
for t in list(positions.keys()):
|
||||||
|
pos = positions[t]
|
||||||
|
# 해당 종목의 현재 봉 찾기
|
||||||
|
t_bars = all_bars[t]
|
||||||
|
t_idx = pos['last_idx']
|
||||||
|
# 시간이 현재 이벤트 이후인 봉이 있으면 업데이트
|
||||||
|
while t_idx + 1 < len(t_bars) and t_bars[t_idx + 1]['ts'] <= ts:
|
||||||
|
t_idx += 1
|
||||||
|
pos['last_idx'] = t_idx
|
||||||
|
cur_bar = t_bars[t_idx]
|
||||||
|
cur_price = cur_bar['close']
|
||||||
|
cur_high = cur_bar['high']
|
||||||
|
cur_low = cur_bar['low']
|
||||||
|
|
||||||
|
# peak 갱신 (봉 고가 기준)
|
||||||
|
pos['running_peak'] = max(pos['running_peak'], cur_high)
|
||||||
|
|
||||||
|
bars_held = t_idx - pos['entry_idx']
|
||||||
|
|
||||||
|
# 봉 내 손절 체크 (저가 기준)
|
||||||
|
loss = (cur_low - pos['entry_price']) / pos['entry_price']
|
||||||
|
if loss <= -STOP_LOSS_PCT:
|
||||||
|
exit_p = pos['entry_price'] * (1 - STOP_LOSS_PCT)
|
||||||
|
pnl = -STOP_LOSS_PCT * 100
|
||||||
|
krw = PER_POS * (-STOP_LOSS_PCT) - PER_POS * FEE * 2
|
||||||
|
trades.append({
|
||||||
|
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
|
||||||
|
'entry': pos['entry_price'], 'exit': exit_p,
|
||||||
|
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'stoploss',
|
||||||
|
'peak': pos['running_peak'],
|
||||||
|
'vol_ratio': pos.get('vol_ratio', 0),
|
||||||
|
'prev_greens': pos.get('prev_greens', 0),
|
||||||
|
'bar_rise_pct': pos.get('bar_rise_pct', 0),
|
||||||
|
})
|
||||||
|
del positions[t]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 트레일링 체크 (종가 기준)
|
||||||
|
profit = (cur_price - pos['entry_price']) / pos['entry_price']
|
||||||
|
drop = (pos['running_peak'] - cur_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
|
||||||
|
|
||||||
|
if profit >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
|
||||||
|
pnl = profit * 100
|
||||||
|
krw = PER_POS * profit - PER_POS * FEE * 2
|
||||||
|
trades.append({
|
||||||
|
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
|
||||||
|
'entry': pos['entry_price'], 'exit': cur_price,
|
||||||
|
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'trail',
|
||||||
|
'peak': pos['running_peak'],
|
||||||
|
'vol_ratio': pos.get('vol_ratio', 0),
|
||||||
|
'prev_greens': pos.get('prev_greens', 0),
|
||||||
|
'bar_rise_pct': pos.get('bar_rise_pct', 0),
|
||||||
|
})
|
||||||
|
del positions[t]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 타임아웃
|
||||||
|
if bars_held >= TIMEOUT_BARS:
|
||||||
|
pnl = profit * 100
|
||||||
|
krw = PER_POS * profit - PER_POS * FEE * 2
|
||||||
|
trades.append({
|
||||||
|
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': cur_bar['ts'],
|
||||||
|
'entry': pos['entry_price'], 'exit': cur_price,
|
||||||
|
'pnl_pct': pnl, 'pnl_krw': krw, 'bars': bars_held, 'tag': 'timeout',
|
||||||
|
'peak': pos['running_peak'],
|
||||||
|
'vol_ratio': pos.get('vol_ratio', 0),
|
||||||
|
'prev_greens': pos.get('prev_greens', 0),
|
||||||
|
'bar_rise_pct': pos.get('bar_rise_pct', 0),
|
||||||
|
})
|
||||||
|
del positions[t]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 시그널 감지 ──
|
||||||
|
if idx < VOL_LOOKBACK + 3:
|
||||||
|
continue
|
||||||
|
if ticker in positions:
|
||||||
|
continue
|
||||||
|
if len(positions) >= MAX_POS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 양봉
|
||||||
|
if b['close'] <= b['open']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 거래량 배수
|
||||||
|
vr = calc_vr(bar_list, idx)
|
||||||
|
if vr < VOL_MIN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 거래대금 하한
|
||||||
|
bar_krw = b['close'] * b['volume']
|
||||||
|
if bar_krw < VOL_KRW_MIN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 횡보 필터 (5봉)
|
||||||
|
recent = bar_list[idx-4:idx+1]
|
||||||
|
p_high = max(x['high'] for x in recent)
|
||||||
|
p_low = min(x['low'] for x in recent)
|
||||||
|
if p_low > 0:
|
||||||
|
spread = (p_high - p_low) / p_low * 100
|
||||||
|
if spread < SPREAD_MIN:
|
||||||
|
skipped_spread += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 고점 필터
|
||||||
|
long_start = max(0, idx - HIGHPOS_BARS)
|
||||||
|
long_bars = bar_list[long_start:idx+1]
|
||||||
|
l_high = max(x['high'] for x in long_bars)
|
||||||
|
l_low = min(x['low'] for x in long_bars)
|
||||||
|
if l_high > l_low:
|
||||||
|
pos_in_range = (b['close'] - l_low) / (l_high - l_low)
|
||||||
|
move_pct = (l_high - l_low) / l_low * 100
|
||||||
|
if pos_in_range > HIGHPOS_THR and move_pct > HIGHPOS_MOVE:
|
||||||
|
skipped_highpos += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 진입 전 연속 양봉 수
|
||||||
|
prev_greens = 0
|
||||||
|
for k in range(idx-1, max(idx-10, 0), -1):
|
||||||
|
if bar_list[k]['close'] > bar_list[k]['open']:
|
||||||
|
prev_greens += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 연속 양봉 필터: 2봉 이상 연속 양봉 필요
|
||||||
|
if prev_greens < 2:
|
||||||
|
skipped_greens += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
signals_count += 1
|
||||||
|
|
||||||
|
# 진입봉 상승폭
|
||||||
|
bar_rise_pct = (b['close'] - b['open']) / b['open'] * 100 if b['open'] > 0 else 0
|
||||||
|
|
||||||
|
# 매수 (현재가)
|
||||||
|
entry_price = b['close']
|
||||||
|
positions[ticker] = {
|
||||||
|
'entry_price': entry_price,
|
||||||
|
'entry_ts': b['ts'],
|
||||||
|
'entry_idx': idx,
|
||||||
|
'last_idx': idx,
|
||||||
|
'running_peak': b['high'],
|
||||||
|
'vol_ratio': vr,
|
||||||
|
'prev_greens': prev_greens,
|
||||||
|
'bar_rise_pct': bar_rise_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 미청산 포지션 강제 종료
|
||||||
|
for t, pos in list(positions.items()):
|
||||||
|
t_bars = all_bars[t]
|
||||||
|
last_bar = t_bars[-1]
|
||||||
|
cur_price = last_bar['close']
|
||||||
|
profit = (cur_price - pos['entry_price']) / pos['entry_price']
|
||||||
|
krw = PER_POS * profit - PER_POS * FEE * 2
|
||||||
|
trades.append({
|
||||||
|
'ticker': t, 'entry_ts': pos['entry_ts'], 'exit_ts': last_bar['ts'],
|
||||||
|
'entry': pos['entry_price'], 'exit': cur_price,
|
||||||
|
'pnl_pct': profit * 100, 'pnl_krw': krw,
|
||||||
|
'bars': len(t_bars) - 1 - pos['entry_idx'], 'tag': 'open',
|
||||||
|
'peak': pos['running_peak'],
|
||||||
|
'vol_ratio': pos.get('vol_ratio', 0),
|
||||||
|
'prev_greens': pos.get('prev_greens', 0),
|
||||||
|
'bar_rise_pct': pos.get('bar_rise_pct', 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 결과 출력 ──
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("결과")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
print(f"\n시그널 발생: {signals_count}건")
|
||||||
|
print(f"필터 제외: 횡보 {skipped_spread}건, 고점 {skipped_highpos}건, 연속양봉<2 {skipped_greens}건")
|
||||||
|
print(f"총 거래: {len(trades)}건\n")
|
||||||
|
|
||||||
|
if not trades:
|
||||||
|
print("거래 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
wins = [t for t in trades if t['pnl_pct'] > 0]
|
||||||
|
losses = [t for t in trades if t['pnl_pct'] <= 0]
|
||||||
|
total_krw = sum(t['pnl_krw'] for t in trades)
|
||||||
|
win_rate = len(wins) / len(trades) * 100
|
||||||
|
|
||||||
|
print(f"승률: {win_rate:.1f}% ({len(wins)}승 {len(losses)}패)")
|
||||||
|
print(f"총 PNL: {total_krw:+,.0f}원")
|
||||||
|
print(f"평균 PNL: {sum(t['pnl_pct'] for t in trades)/len(trades):+.2f}%")
|
||||||
|
if wins:
|
||||||
|
print(f"평균 수익 (승): +{sum(t['pnl_pct'] for t in wins)/len(wins):.2f}%")
|
||||||
|
if losses:
|
||||||
|
print(f"평균 손실 (패): {sum(t['pnl_pct'] for t in losses)/len(losses):+.2f}%")
|
||||||
|
|
||||||
|
# 청산 유형별
|
||||||
|
by_tag = defaultdict(list)
|
||||||
|
for t in trades:
|
||||||
|
by_tag[t['tag']].append(t)
|
||||||
|
print("\n[청산 유형별]")
|
||||||
|
for tag, tlist in sorted(by_tag.items()):
|
||||||
|
avg_pnl = sum(t['pnl_pct'] for t in tlist) / len(tlist)
|
||||||
|
total = sum(t['pnl_krw'] for t in tlist)
|
||||||
|
print(f" {tag:10s}: {len(tlist):3d}건 avg {avg_pnl:+.2f}% 합계 {total:+,.0f}원")
|
||||||
|
|
||||||
|
# 종목별
|
||||||
|
by_ticker = defaultdict(list)
|
||||||
|
for t in trades:
|
||||||
|
by_ticker[t['ticker']].append(t)
|
||||||
|
print("\n[종목별]")
|
||||||
|
for ticker in TICKERS:
|
||||||
|
tlist = by_ticker.get(ticker, [])
|
||||||
|
if not tlist:
|
||||||
|
continue
|
||||||
|
w = sum(1 for t in tlist if t['pnl_pct'] > 0)
|
||||||
|
total = sum(t['pnl_krw'] for t in tlist)
|
||||||
|
avg = sum(t['pnl_pct'] for t in tlist) / len(tlist)
|
||||||
|
print(f" {ticker:12s}: {len(tlist):3d}건 {w}승{len(tlist)-w}패 avg {avg:+.2f}% 합계 {total:+,.0f}원")
|
||||||
|
|
||||||
|
# 승패별 시그널 특성 분석
|
||||||
|
print("\n[승패별 시그널 특성]")
|
||||||
|
win_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] > 0]
|
||||||
|
loss_vrs = [t['vol_ratio'] for t in trades if t['pnl_pct'] <= 0]
|
||||||
|
print(f" 승리 거래량비: 평균 {sum(win_vrs)/len(win_vrs):.1f}x 중간값 {sorted(win_vrs)[len(win_vrs)//2]:.1f}x")
|
||||||
|
print(f" 패배 거래량비: 평균 {sum(loss_vrs)/len(loss_vrs):.1f}x 중간값 {sorted(loss_vrs)[len(loss_vrs)//2]:.1f}x")
|
||||||
|
|
||||||
|
# 거래량비 구간별 승률
|
||||||
|
print("\n[거래량비 구간별 승률]")
|
||||||
|
for lo, hi in [(5,7),(7,10),(10,15),(15,30),(30,999)]:
|
||||||
|
subset = [t for t in trades if lo <= t['vol_ratio'] < hi]
|
||||||
|
if not subset:
|
||||||
|
continue
|
||||||
|
w = sum(1 for t in subset if t['pnl_pct'] > 0)
|
||||||
|
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
|
||||||
|
print(f" {lo:3d}~{hi:3d}x: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
|
||||||
|
|
||||||
|
# 연속 양봉 여부별 승률
|
||||||
|
print("\n[진입 전 연속 양봉 수별 승률]")
|
||||||
|
for n_green in [1, 2, 3]:
|
||||||
|
subset = [t for t in trades if t.get('prev_greens', 0) >= n_green]
|
||||||
|
if not subset:
|
||||||
|
continue
|
||||||
|
w = sum(1 for t in subset if t['pnl_pct'] > 0)
|
||||||
|
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
|
||||||
|
print(f" {n_green}봉+ 연속양봉: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
|
||||||
|
|
||||||
|
# 진입봉 상승폭별 승률
|
||||||
|
print("\n[진입봉 상승폭별 승률]")
|
||||||
|
for lo, hi in [(0,0.3),(0.3,0.7),(0.7,1.5),(1.5,100)]:
|
||||||
|
subset = [t for t in trades if lo <= t.get('bar_rise_pct', 0) < hi]
|
||||||
|
if not subset:
|
||||||
|
continue
|
||||||
|
w = sum(1 for t in subset if t['pnl_pct'] > 0)
|
||||||
|
avg_pnl = sum(t['pnl_pct'] for t in subset) / len(subset)
|
||||||
|
print(f" {lo:.1f}~{hi:.1f}%: {len(subset):3d}건 승률 {w/len(subset)*100:.0f}% avg {avg_pnl:+.2f}%")
|
||||||
|
|
||||||
|
# 상위 거래
|
||||||
|
trades_sorted = sorted(trades, key=lambda x: -x['pnl_pct'])
|
||||||
|
print("\n[TOP 10 수익]")
|
||||||
|
for t in trades_sorted[:10]:
|
||||||
|
peak_pnl = (t['peak'] - t['entry']) / t['entry'] * 100
|
||||||
|
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f} → {t['exit']:>12,.1f} "
|
||||||
|
f"PNL {t['pnl_pct']:+.2f}% 고점 +{peak_pnl:.1f}% {t['bars']}봉 [{t['tag']}]")
|
||||||
|
|
||||||
|
print("\n[WORST 10 손실]")
|
||||||
|
for t in trades_sorted[-10:]:
|
||||||
|
print(f" {t['ticker']:12s} {str(t['entry_ts'])[5:16]} {t['entry']:>12,.1f} → {t['exit']:>12,.1f} "
|
||||||
|
f"PNL {t['pnl_pct']:+.2f}% {t['bars']}봉 [{t['tag']}]")
|
||||||
|
|
||||||
|
# 일별 PNL
|
||||||
|
by_date = defaultdict(float)
|
||||||
|
by_date_cnt = defaultdict(int)
|
||||||
|
for t in trades:
|
||||||
|
d = str(t['exit_ts'])[:10]
|
||||||
|
by_date[d] += t['pnl_krw']
|
||||||
|
by_date_cnt[d] += 1
|
||||||
|
print("\n[일별 PNL]")
|
||||||
|
for d in sorted(by_date.keys()):
|
||||||
|
print(f" {d}: {by_date_cnt[d]:3d}건 {by_date[d]:+,.0f}원")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_backtest()
|
||||||
697
core/llm_advisor.py
Normal file
697
core/llm_advisor.py
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
"""OpenRouter LLM 기반 매매 어드바이저.
|
||||||
|
|
||||||
|
매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정
|
||||||
|
매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback)
|
||||||
|
|
||||||
|
LLM에게 제공하는 DB Tool (OpenAI function calling):
|
||||||
|
- get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱)
|
||||||
|
- get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용)
|
||||||
|
- get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스)
|
||||||
|
- get_trade_history(ticker): 최근 거래 이력 (승패, 손익)
|
||||||
|
- get_btc_trend(): BTC 최근 동향 (알트 매수 판단용)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 프롬프트에 포함할 봉 수
|
||||||
|
INPUT_BARS = 20
|
||||||
|
|
||||||
|
|
||||||
|
# ── Oracle DB Tools ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
"""Oracle ADB 연결 (.env 기반)."""
|
||||||
|
import oracledb
|
||||||
|
kwargs = dict(
|
||||||
|
user = os.environ['ORACLE_USER'],
|
||||||
|
password = os.environ['ORACLE_PASSWORD'],
|
||||||
|
dsn = os.environ['ORACLE_DSN'],
|
||||||
|
)
|
||||||
|
if w := os.environ.get('ORACLE_WALLET'):
|
||||||
|
kwargs['config_dir'] = w
|
||||||
|
return oracledb.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_get_price_ticks(ticker: str, minutes: int = 10) -> str:
|
||||||
|
"""Oracle price_tick 테이블에서 최근 N분 가격 틱 조회."""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT ts, price
|
||||||
|
FROM price_tick
|
||||||
|
WHERE ticker = :t
|
||||||
|
AND ts >= SYSTIMESTAMP - NUMTODSINTERVAL(:m, 'MINUTE')
|
||||||
|
ORDER BY ts DESC
|
||||||
|
FETCH FIRST 100 ROWS ONLY""",
|
||||||
|
{'t': ticker, 'm': minutes},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
return f"{ticker} 최근 {minutes}분 틱 데이터 없음"
|
||||||
|
lines = [f" {r[0].strftime('%H:%M:%S')} {float(r[1]):>12,.2f}원" for r in rows]
|
||||||
|
return f"{ticker} 최근 {minutes}분 가격 틱 ({len(rows)}건):\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_get_ohlcv(ticker: str, limit: int = 30) -> str:
|
||||||
|
"""Oracle backtest_ohlcv 1분봉 최근 N개 조회 (지지/저항 파악용)."""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT ts, open_p, high_p, low_p, close_p, volume_p
|
||||||
|
FROM backtest_ohlcv
|
||||||
|
WHERE ticker = :t
|
||||||
|
AND interval_cd = 'minute1'
|
||||||
|
ORDER BY ts DESC
|
||||||
|
FETCH FIRST :n ROWS ONLY""",
|
||||||
|
{'t': ticker, 'n': limit},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
return f"{ticker} 1분봉 데이터 없음"
|
||||||
|
lines = [
|
||||||
|
f" {r[0].strftime('%H:%M')} O{float(r[1]):>10,.0f} H{float(r[2]):>10,.0f}"
|
||||||
|
f" L{float(r[3]):>10,.0f} C{float(r[4]):>10,.0f} V{float(r[5]):.0f}"
|
||||||
|
for r in reversed(rows)
|
||||||
|
]
|
||||||
|
return f"{ticker} 1분봉 최근 {len(rows)}개:\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 종목 컨텍스트 조회 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tool_get_context(ticker: str) -> str:
|
||||||
|
"""ticker_context 테이블에서 종목의 가격 통계 + 뉴스 조회."""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT context_type, content FROM ticker_context WHERE ticker = :t",
|
||||||
|
{'t': ticker},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
conn.close()
|
||||||
|
return f"{ticker} 컨텍스트 데이터 없음"
|
||||||
|
parts = []
|
||||||
|
for ctx_type, content in rows:
|
||||||
|
# CLOB(LOB) → str 변환 (conn 닫기 전에 읽어야 함)
|
||||||
|
text = content.read() if hasattr(content, 'read') else str(content)
|
||||||
|
parts.append(f"[{ctx_type}]\n{text}")
|
||||||
|
conn.close()
|
||||||
|
return f"{ticker} 종목 컨텍스트:\n" + "\n\n".join(parts)
|
||||||
|
except Exception as e:
|
||||||
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 거래 이력 조회 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tool_get_trade_history(ticker: str, limit: int = 10) -> str:
|
||||||
|
"""trade_results 테이블에서 해당 종목 최근 거래 이력 조회."""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT traded_at, is_win, pnl_pct, buy_price, sell_price, sell_reason
|
||||||
|
FROM trade_results
|
||||||
|
WHERE ticker = :t
|
||||||
|
ORDER BY traded_at DESC
|
||||||
|
FETCH FIRST :n ROWS ONLY""",
|
||||||
|
{'t': ticker, 'n': limit},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
return f"{ticker} 거래 이력 없음"
|
||||||
|
lines = []
|
||||||
|
wins = sum(1 for r in rows if r[1])
|
||||||
|
for r in rows:
|
||||||
|
ts = r[0].strftime('%m/%d %H:%M') if r[0] else '?'
|
||||||
|
wl = '승' if r[1] else '패'
|
||||||
|
pnl = float(r[2]) if r[2] else 0
|
||||||
|
reason = r[5] or ''
|
||||||
|
lines.append(f" {ts} {wl} {pnl:+.2f}% {reason}")
|
||||||
|
header = f"{ticker} 최근 {len(rows)}건 (승률 {wins}/{len(rows)}={wins/len(rows)*100:.0f}%):"
|
||||||
|
note = "\n ※ 과거 성과는 참고용입니다. 현재 시그널 강도가 높으면 과거 연패와 무관하게 진입하세요."
|
||||||
|
return header + "\n" + "\n".join(lines) + note
|
||||||
|
except Exception as e:
|
||||||
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_get_btc_trend() -> str:
|
||||||
|
"""BTC 최근 동향 (1시간봉 6개 + 일봉)."""
|
||||||
|
try:
|
||||||
|
import pyupbit
|
||||||
|
lines = []
|
||||||
|
# 일봉 2개
|
||||||
|
day_df = pyupbit.get_ohlcv('KRW-BTC', interval='day', count=2)
|
||||||
|
if day_df is not None and len(day_df) >= 2:
|
||||||
|
today = day_df.iloc[-1]
|
||||||
|
prev = day_df.iloc[-2]
|
||||||
|
chg = (today['close'] - prev['close']) / prev['close'] * 100
|
||||||
|
lines.append(f"[BTC 일봉] 현재 {today['close']:,.0f}원 (전일 대비 {chg:+.2f}%)")
|
||||||
|
lines.append(f" 고가 {today['high']:,.0f} 저가 {today['low']:,.0f}")
|
||||||
|
|
||||||
|
# 1시간봉 6개
|
||||||
|
h1_df = pyupbit.get_ohlcv('KRW-BTC', interval='minute60', count=6)
|
||||||
|
if h1_df is not None and not h1_df.empty:
|
||||||
|
first_c = float(h1_df['close'].iloc[0])
|
||||||
|
last_c = float(h1_df['close'].iloc[-1])
|
||||||
|
h_chg = (last_c - first_c) / first_c * 100
|
||||||
|
trend = '상승' if h_chg > 0.5 else '하락' if h_chg < -0.5 else '횡보'
|
||||||
|
lines.append(f"[BTC 6시간 추세] {trend} ({h_chg:+.2f}%)")
|
||||||
|
for ts, row in h1_df.iterrows():
|
||||||
|
lines.append(
|
||||||
|
f" {ts.strftime('%H:%M')} 종{row['close']:>12,.0f} "
|
||||||
|
f"거래량{row['volume']:,.2f}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines) if lines else "BTC 데이터 조회 실패"
|
||||||
|
except Exception as e:
|
||||||
|
return f"BTC 조회 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
|
||||||
|
|
||||||
|
_TOOLS = [
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_price_ticks',
|
||||||
|
'description': (
|
||||||
|
'Oracle DB에서 특정 종목의 최근 N분간 가격 틱 데이터를 조회합니다. '
|
||||||
|
'단기 가격 흐름과 지지/저항 수준 파악에 사용하세요.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
|
||||||
|
'minutes': {'type': 'integer', 'description': '최근 N분 데이터 (기본 10)'},
|
||||||
|
},
|
||||||
|
'required': ['ticker'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_ohlcv',
|
||||||
|
'description': (
|
||||||
|
'Oracle DB에서 특정 종목의 1분봉 OHLCV 데이터를 조회합니다. '
|
||||||
|
'지지선/저항선 파악과 추세 분석에 사용하세요.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
|
||||||
|
'limit': {'type': 'integer', 'description': '조회할 봉 수 (기본 30)'},
|
||||||
|
},
|
||||||
|
'required': ['ticker'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_ticker_context',
|
||||||
|
'description': (
|
||||||
|
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
|
||||||
|
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
|
||||||
|
'매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
|
||||||
|
},
|
||||||
|
'required': ['ticker'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_trade_history',
|
||||||
|
'description': (
|
||||||
|
'특정 종목의 최근 거래 이력을 조회합니다. '
|
||||||
|
'승률, 손익, 매도 사유 등을 확인해 이 종목의 과거 성과를 파악하세요.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
|
||||||
|
'limit': {'type': 'integer', 'description': '조회 건수 (기본 10)'},
|
||||||
|
},
|
||||||
|
'required': ['ticker'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_btc_trend',
|
||||||
|
'description': (
|
||||||
|
'BTC(비트코인)의 최근 가격 동향을 조회합니다. '
|
||||||
|
'알트코인 매수 전 BTC 추세를 반드시 확인하세요. '
|
||||||
|
'BTC 하락 시 알트코인 동반 하락 위험이 높습니다.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_tool(tool_name: str, tool_input: dict) -> str:
|
||||||
|
"""LLM이 요청한 tool을 실행하고 결과를 반환."""
|
||||||
|
if tool_name == 'get_price_ticks':
|
||||||
|
return _tool_get_price_ticks(
|
||||||
|
ticker = tool_input['ticker'],
|
||||||
|
minutes = tool_input.get('minutes', 10),
|
||||||
|
)
|
||||||
|
if tool_name == 'get_ohlcv':
|
||||||
|
return _tool_get_ohlcv(
|
||||||
|
ticker = tool_input['ticker'],
|
||||||
|
limit = tool_input.get('limit', 30),
|
||||||
|
)
|
||||||
|
if tool_name == 'get_ticker_context':
|
||||||
|
return _tool_get_context(ticker=tool_input['ticker'])
|
||||||
|
if tool_name == 'get_trade_history':
|
||||||
|
return _tool_get_trade_history(
|
||||||
|
ticker=tool_input['ticker'],
|
||||||
|
limit=tool_input.get('limit', 10),
|
||||||
|
)
|
||||||
|
if tool_name == 'get_btc_trend':
|
||||||
|
return _tool_get_btc_trend()
|
||||||
|
return f'알 수 없는 tool: {tool_name}'
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시장 컨텍스트 수집 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_market_context(ticker: str) -> str:
|
||||||
|
"""오늘 일봉 요약 + 최근 4시간 1시간봉을 LLM 프롬프트용 텍스트로 반환.
|
||||||
|
|
||||||
|
pyupbit API 호출 실패 시 빈 문자열 반환 (graceful degradation).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pyupbit
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# 오늘 일봉 (오늘 + 전일 2개)
|
||||||
|
day_df = pyupbit.get_ohlcv(ticker, interval='day', count=2)
|
||||||
|
if day_df is not None and not day_df.empty:
|
||||||
|
today = day_df.iloc[-1]
|
||||||
|
prev = day_df.iloc[-2] if len(day_df) > 1 else None
|
||||||
|
vol_note = ''
|
||||||
|
if prev is not None and prev['volume'] > 0:
|
||||||
|
vol_note = f' (전일 대비 {today["volume"] / prev["volume"]:.1f}x)'
|
||||||
|
lines.append('[오늘 일봉]')
|
||||||
|
lines.append(f' 고가 {today["high"]:>12,.0f}원 저가 {today["low"]:>12,.0f}원')
|
||||||
|
lines.append(f' 시가 {today["open"]:>12,.0f}원 거래량 {today["volume"]:,.0f}{vol_note}')
|
||||||
|
|
||||||
|
# 최근 4시간 1시간봉
|
||||||
|
h1_df = pyupbit.get_ohlcv(ticker, interval='minute60', count=4)
|
||||||
|
if h1_df is not None and not h1_df.empty:
|
||||||
|
lines.append(f'[최근 {len(h1_df)}시간봉]')
|
||||||
|
for ts, row in h1_df.iterrows():
|
||||||
|
lines.append(
|
||||||
|
f' {ts.strftime("%H:%M")} '
|
||||||
|
f'고{row["high"]:>10,.0f} 저{row["low"]:>10,.0f} '
|
||||||
|
f'종{row["close"]:>10,.0f} 거래량{row["volume"]:,.0f}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f'[LLM] 시장 컨텍스트 조회 실패: {e}')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# ── 프롬프트 빌더 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _describe_bars(bar_list: list[dict], current_price: float) -> str:
|
||||||
|
"""봉 데이터를 LLM이 읽기 쉬운 텍스트로 변환."""
|
||||||
|
recent = bar_list[-INPUT_BARS:] if len(bar_list) >= INPUT_BARS else bar_list
|
||||||
|
if not recent:
|
||||||
|
return '봉 데이터 없음'
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for b in recent:
|
||||||
|
ts_str = b['ts'].strftime('%H:%M:%S') if isinstance(b.get('ts'), datetime) else ''
|
||||||
|
lines.append(
|
||||||
|
f' {ts_str} 종가{b["close"]:>10,.0f} 고가{b["high"]:>10,.0f}'
|
||||||
|
f' 저가{b["low"]:>10,.0f}'
|
||||||
|
)
|
||||||
|
|
||||||
|
highs5 = [b['high'] for b in recent[-5:]]
|
||||||
|
closes5 = [b['close'] for b in recent[-5:]]
|
||||||
|
period_high = max(b['high'] for b in recent)
|
||||||
|
from_peak = (current_price - period_high) / period_high * 100
|
||||||
|
|
||||||
|
trend_h = '하락▼' if highs5[-1] < highs5[0] else '상승▲' if highs5[-1] > highs5[0] else '횡보─'
|
||||||
|
trend_c = '하락▼' if closes5[-1] < closes5[0] else '상승▲' if closes5[-1] > closes5[0] else '횡보─'
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f'\n[패턴 요약]\n'
|
||||||
|
f'- 고가 추세: {trend_h} ({highs5[0]:,.0f}→{highs5[-1]:,.0f})\n'
|
||||||
|
f'- 종가 추세: {trend_c} ({closes5[0]:,.0f}→{closes5[-1]:,.0f})\n'
|
||||||
|
f'- 구간 최고가: {period_high:,.0f}원 현재가 대비: {from_peak:+.2f}%'
|
||||||
|
)
|
||||||
|
return '\n'.join(lines) + summary
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_momentum(bar_list: list[dict], current_price: float, bar_sec: int = 20) -> str:
|
||||||
|
"""다중 타임프레임 모멘텀 요약. 매도 LLM에 상승 곡선 판단 근거 제공."""
|
||||||
|
if len(bar_list) < 10:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
# 1분(3봉), 3분(9봉), 5분(15봉), 10분(30봉) 전 가격 대비 변화율
|
||||||
|
intervals = [
|
||||||
|
('1분', 3), ('3분', 9), ('5분', 15), ('10분', 30),
|
||||||
|
]
|
||||||
|
for label, n_bars in intervals:
|
||||||
|
if len(bar_list) < n_bars + 1:
|
||||||
|
continue
|
||||||
|
past_price = bar_list[-(n_bars + 1)]['close']
|
||||||
|
chg = (current_price - past_price) / past_price * 100
|
||||||
|
arrow = '▲' if chg > 0.3 else '▼' if chg < -0.3 else '─'
|
||||||
|
lines.append(f' {label}전 대비: {chg:+.2f}% {arrow} ({past_price:,.0f}→{current_price:,.0f})')
|
||||||
|
|
||||||
|
# 최근 15봉 연속 상승/하락 카운트
|
||||||
|
recent = bar_list[-15:]
|
||||||
|
up_count = sum(1 for b in recent if b['close'] > b['open'])
|
||||||
|
dn_count = sum(1 for b in recent if b['close'] < b['open'])
|
||||||
|
|
||||||
|
# 최근 15봉 최저가 → 현재가 상승폭
|
||||||
|
period_low = min(b['low'] for b in recent)
|
||||||
|
rise_from_low = (current_price - period_low) / period_low * 100
|
||||||
|
|
||||||
|
lines.append(f' 최근 15봉: 양봉 {up_count}개 / 음봉 {dn_count}개')
|
||||||
|
lines.append(f' 구간 저점 대비: {rise_from_low:+.2f}% ({period_low:,.0f}→{current_price:,.0f})')
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return ''
|
||||||
|
return '[모멘텀 분석]\n' + '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(
|
||||||
|
ticker: str,
|
||||||
|
entry_price: float,
|
||||||
|
current_price: float,
|
||||||
|
elapsed_min: float,
|
||||||
|
current_target: float,
|
||||||
|
bar_desc: str,
|
||||||
|
market_context: str = '',
|
||||||
|
momentum_desc: str = '',
|
||||||
|
) -> str:
|
||||||
|
pnl_pct = (current_price - entry_price) / entry_price * 100
|
||||||
|
target_gap = (current_target - current_price) / current_price * 100
|
||||||
|
|
||||||
|
market_section = f'\n{market_context}\n' if market_context else ''
|
||||||
|
momentum_section = f'\n{momentum_desc}\n' if momentum_desc else ''
|
||||||
|
|
||||||
|
return f"""당신은 암호화폐 단기 트레이더입니다.
|
||||||
|
아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요.
|
||||||
|
필요하면 제공된 DB tool을 호출해 추가 데이터를 조회하세요.
|
||||||
|
특히 get_ticker_context로 종목의 24h/7d 가격 변동, 거래량 추이, 최근 뉴스를 확인하세요.
|
||||||
|
|
||||||
|
[현재 포지션]
|
||||||
|
종목 : {ticker}
|
||||||
|
진입가 : {entry_price:,.0f}원
|
||||||
|
현재가 : {current_price:,.0f}원 ({pnl_pct:+.2f}%)
|
||||||
|
보유시간: {elapsed_min:.0f}분
|
||||||
|
현재 지정가: {current_target:,.0f}원 (현재가 대비 {target_gap:+.2f}%, 미체결)
|
||||||
|
{market_section}
|
||||||
|
[최근 {INPUT_BARS}봉 (20초봉)]
|
||||||
|
{bar_desc}
|
||||||
|
{momentum_section}
|
||||||
|
[운용 정책 참고 — 최종 판단은 당신이 결정]
|
||||||
|
- 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정)
|
||||||
|
- 수익 목표: 진입가 대비 +0.5% ~ +2% 구간
|
||||||
|
- 손절 기준: 진입가 대비 -2% 이하이면 즉시 시장가 매도를 강력 권고 (action=sell, price=현재가)
|
||||||
|
- 체결 가능성이 낮으면 현실적인 목표가로 조정 권장
|
||||||
|
- **모멘텀이 강하면(1분~10분 전 대비 계속 상승 중) 성급하게 팔지 말고 hold하세요**
|
||||||
|
- **양봉 비율이 높고 구간 저점 대비 상승폭이 크면 추세가 살아있는 것입니다**
|
||||||
|
- 상승 여력이 있으면 hold 권장
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
|
||||||
|
|
||||||
|
매도 지정가를 설정할 경우:
|
||||||
|
{{"action": "sell", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": false}}
|
||||||
|
|
||||||
|
현재 주문을 유지할 경우:
|
||||||
|
{{"action": "hold", "reason": "유지 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": true/false}}
|
||||||
|
|
||||||
|
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 공통 LLM 호출 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _call_llm(prompt: str, ticker: str) -> Optional[dict]:
|
||||||
|
"""OpenRouter API를 호출하고 JSON 응답을 반환. 실패 시 None."""
|
||||||
|
import requests as _req
|
||||||
|
import re
|
||||||
|
|
||||||
|
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
||||||
|
if not api_key:
|
||||||
|
log.debug('[LLM] OPENROUTER_API_KEY 없음')
|
||||||
|
return None
|
||||||
|
|
||||||
|
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4.5')
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
messages = [{'role': 'user', 'content': prompt}]
|
||||||
|
|
||||||
|
max_rounds = 5
|
||||||
|
called_tools: set = set() # 중복 tool 호출 방지
|
||||||
|
try:
|
||||||
|
for round_i in range(max_rounds):
|
||||||
|
body = {
|
||||||
|
'model': model,
|
||||||
|
'max_tokens': 512,
|
||||||
|
'messages': messages,
|
||||||
|
'response_format': {'type': 'json_object'},
|
||||||
|
}
|
||||||
|
# 마지막 라운드 또는 모든 tool 이미 호출 → tool 제거해 강제 텍스트 응답
|
||||||
|
if round_i < max_rounds - 1 and len(called_tools) < len(_TOOLS):
|
||||||
|
body['tools'] = _TOOLS
|
||||||
|
resp = _req.post(
|
||||||
|
'https://openrouter.ai/api/v1/chat/completions',
|
||||||
|
headers=headers, json=body, timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
choice = result['choices'][0]
|
||||||
|
message = choice['message']
|
||||||
|
|
||||||
|
tool_calls = message.get('tool_calls')
|
||||||
|
if tool_calls:
|
||||||
|
messages.append(message)
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn_name = tc['function']['name']
|
||||||
|
fn_args = json.loads(tc['function']['arguments'])
|
||||||
|
call_key = f'{fn_name}:{json.dumps(fn_args, sort_keys=True)}'
|
||||||
|
if call_key in called_tools:
|
||||||
|
# 중복 호출 → 캐시된 결과 반환
|
||||||
|
fn_result = f'(이미 조회한 데이터입니다. 위 결과를 참고하세요.)'
|
||||||
|
else:
|
||||||
|
fn_result = _execute_tool(fn_name, fn_args)
|
||||||
|
called_tools.add(call_key)
|
||||||
|
log.info(f'[LLM-Tool] {ticker} {fn_name} 호출')
|
||||||
|
messages.append({
|
||||||
|
'role': 'tool',
|
||||||
|
'tool_call_id': tc['id'],
|
||||||
|
'content': fn_result,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = (message.get('content') or '').strip()
|
||||||
|
if not raw:
|
||||||
|
log.warning(f'[LLM] {ticker} 빈 응답')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 코드블록 안 JSON 추출
|
||||||
|
if '```' in raw:
|
||||||
|
m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
raw = m.group(1)
|
||||||
|
# 텍스트에 섞인 JSON 객체 추출
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
m = re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', raw, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:200]}')
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
log.warning(f'[LLM] {ticker} tool 루프 초과')
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'[LLM] {ticker} 오류: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매수 판단 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_entry_price(
|
||||||
|
ticker: str,
|
||||||
|
signal: dict,
|
||||||
|
bar_list: list[dict],
|
||||||
|
current_price: float,
|
||||||
|
fng: int = 50,
|
||||||
|
num_positions: int = 0,
|
||||||
|
max_positions: int = 3,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""LLM에게 매수 여부 + 지정가를 물어본다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 종목 코드
|
||||||
|
signal: 시그널 정보 {vol_ratio, prices, ...}
|
||||||
|
bar_list: 최근 20초봉 리스트
|
||||||
|
current_price: 현재 가격
|
||||||
|
fng: 현재 F&G 지수
|
||||||
|
num_positions: 현재 보유 포지션 수
|
||||||
|
max_positions: 최대 포지션 수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float → 지정가 매수 가격
|
||||||
|
None → 매수하지 않음
|
||||||
|
"""
|
||||||
|
bar_desc = _describe_bars(bar_list, current_price)
|
||||||
|
mkt_ctx = _get_market_context(ticker)
|
||||||
|
|
||||||
|
vol_ratio = signal.get('vol_ratio', 0)
|
||||||
|
market_section = f'\n{mkt_ctx}\n' if mkt_ctx else ''
|
||||||
|
|
||||||
|
prompt = f"""당신은 암호화폐 단기 트레이더입니다.
|
||||||
|
아래 시그널을 분석해 **매수 여부**를 판단하세요. (매수 가격은 현재가로 자동 설정됩니다)
|
||||||
|
반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요:
|
||||||
|
- get_btc_trend: BTC 추세 확인 (필수 — BTC 급락 시 알트 매수 위험)
|
||||||
|
- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인
|
||||||
|
- get_ohlcv: 1분봉으로 현재 추세 확인
|
||||||
|
|
||||||
|
[시그널 감지]
|
||||||
|
종목 : {ticker}
|
||||||
|
현재가 : {current_price:,.0f}원
|
||||||
|
거래량비: {vol_ratio:.1f}x (61봉 평균 대비)
|
||||||
|
F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕'})
|
||||||
|
포지션 : {num_positions}/{max_positions}
|
||||||
|
{market_section}
|
||||||
|
[최근 {INPUT_BARS}봉 (20초봉)]
|
||||||
|
{bar_desc}
|
||||||
|
|
||||||
|
[판단 기준]
|
||||||
|
- 거래량 급증 시그널이 왔으면 빠르게 올라타는 것이 핵심
|
||||||
|
- BTC가 급락 중(-2% 이상)이면 자제하되, 횡보/소폭하락은 진입 OK
|
||||||
|
- 상승 추세가 이미 많이 진행됐으면(+5% 이상) 진입 자제
|
||||||
|
- **과거 연패/승률은 절대 고려하지 마세요. 현재 시그널만 보고 판단하세요.**
|
||||||
|
- **get_trade_history를 호출하지 마세요. 과거 거래 이력은 판단에 불필요합니다.**
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
|
||||||
|
|
||||||
|
매수할 경우:
|
||||||
|
{{"action": "buy", "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
|
||||||
|
|
||||||
|
매수하지 않을 경우:
|
||||||
|
{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}"""
|
||||||
|
|
||||||
|
data = _call_llm(prompt, ticker)
|
||||||
|
if data is None:
|
||||||
|
log.info(f'[LLM매수] {ticker} → LLM 오류/무응답')
|
||||||
|
return None
|
||||||
|
|
||||||
|
reason = data.get('reason', '')
|
||||||
|
status = data.get('market_status', '')
|
||||||
|
confidence = data.get('confidence', '?')
|
||||||
|
|
||||||
|
if data.get('action') == 'skip':
|
||||||
|
log.info(f'[LLM매수] {ticker} → skip | {confidence} | {status} | {reason}')
|
||||||
|
return data # action=skip
|
||||||
|
|
||||||
|
if data.get('action') == 'buy':
|
||||||
|
data['price'] = float(data['price'])
|
||||||
|
log.info(f'[LLM매수] {ticker} → buy {data["price"]:,.2f}원 | {confidence} | {status} | {reason}')
|
||||||
|
return data # action=buy, price=float
|
||||||
|
|
||||||
|
log.warning(f'[LLM매수] {ticker} 알 수 없는 action: {data}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매도 판단 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_exit_price(
|
||||||
|
ticker: str,
|
||||||
|
pos: dict,
|
||||||
|
bar_list: list[dict],
|
||||||
|
current_price: float,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""LLM에게 매도 목표가를 물어본다. (DB tool 사용 가능)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 종목 코드 (예: 'KRW-XRP')
|
||||||
|
pos: positions[ticker] 딕셔너리
|
||||||
|
bar_list: list(bars[ticker]) — 최신봉이 마지막
|
||||||
|
current_price: 현재 틱 가격
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
|
||||||
|
None → hold 또는 오류
|
||||||
|
"""
|
||||||
|
entry_price = pos['entry_price']
|
||||||
|
elapsed_min = (datetime.now() - pos['entry_ts']).total_seconds() / 60
|
||||||
|
current_target = pos.get('sell_price') or entry_price * 1.005
|
||||||
|
|
||||||
|
bar_desc = _describe_bars(bar_list, current_price)
|
||||||
|
mkt_ctx = _get_market_context(ticker)
|
||||||
|
momentum_desc = _calc_momentum(bar_list, current_price)
|
||||||
|
prompt = _build_prompt(
|
||||||
|
ticker, entry_price, current_price,
|
||||||
|
elapsed_min, current_target, bar_desc,
|
||||||
|
market_context=mkt_ctx,
|
||||||
|
momentum_desc=momentum_desc,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _call_llm(prompt, ticker)
|
||||||
|
if data is None:
|
||||||
|
log.info(f'[LLM매도] {ticker} → LLM 오류/무응답 → cascade fallback')
|
||||||
|
return None
|
||||||
|
|
||||||
|
reason = data.get('reason', '')
|
||||||
|
status = data.get('market_status', '')
|
||||||
|
confidence = data.get('confidence', '?')
|
||||||
|
watch = data.get('watch_needed', False)
|
||||||
|
|
||||||
|
if data.get('action') == 'hold':
|
||||||
|
log.info(f'[LLM매도] {ticker} → hold | {confidence} | {status} | watch={watch} | {reason}')
|
||||||
|
return data # action=hold
|
||||||
|
|
||||||
|
data['price'] = float(data['price'])
|
||||||
|
pnl_from_entry = (data['price'] - entry_price) / entry_price * 100
|
||||||
|
log.info(
|
||||||
|
f'[LLM매도] {ticker} 지정가 교체: {current_target:,.2f} → {data["price"]:,.2f}원 '
|
||||||
|
f'(진입 대비 {pnl_from_entry:+.2f}%) | {confidence} | {status} | watch={watch} | {reason}'
|
||||||
|
)
|
||||||
|
return data # action=sell, price=float
|
||||||
226
daemons/context_collector.py
Normal file
226
daemons/context_collector.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""종목 컨텍스트 수집 데몬.
|
||||||
|
|
||||||
|
1시간마다 각 종목의 평판 정보를 수집해 Oracle ticker_context 테이블에 저장.
|
||||||
|
LLM 어드바이저가 매도 판단 시 참조.
|
||||||
|
|
||||||
|
수집 항목:
|
||||||
|
- price_stats: 24h/7d 가격 변동률, 거래량 추이
|
||||||
|
- news: SearXNG 웹 검색 뉴스 요약
|
||||||
|
|
||||||
|
실행:
|
||||||
|
.venv/bin/python3 daemons/context_collector.py
|
||||||
|
로그:
|
||||||
|
/tmp/context_collector.log
|
||||||
|
"""
|
||||||
|
import sys, os, time, logging, json, requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
TICKERS = [
|
||||||
|
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
|
||||||
|
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
|
||||||
|
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
|
||||||
|
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
|
||||||
|
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
|
||||||
|
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
|
||||||
|
]
|
||||||
|
|
||||||
|
COLLECT_INTERVAL = 3600 # 1시간
|
||||||
|
SEARXNG_URL = 'https://searxng.cloud-handson.com/search'
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/tmp/context_collector.log'),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 코인명 매핑 (검색 키워드용)
|
||||||
|
COIN_NAMES = {
|
||||||
|
'KRW-BTC': 'Bitcoin', 'KRW-ETH': 'Ethereum', 'KRW-XRP': 'Ripple XRP',
|
||||||
|
'KRW-SOL': 'Solana', 'KRW-DOGE': 'Dogecoin', 'KRW-ADA': 'Cardano',
|
||||||
|
'KRW-SUI': 'SUI', 'KRW-NEAR': 'NEAR Protocol', 'KRW-KAVA': 'KAVA',
|
||||||
|
'KRW-SXP': 'Solar SXP', 'KRW-AKT': 'Akash', 'KRW-SONIC': 'Sonic',
|
||||||
|
'KRW-IP': 'Story IP', 'KRW-ORBS': 'ORBS', 'KRW-VIRTUAL': 'Virtuals Protocol',
|
||||||
|
'KRW-BARD': 'BARD', 'KRW-XPL': 'XPL', 'KRW-KITE': 'KITE',
|
||||||
|
'KRW-ENSO': 'ENSO', 'KRW-0G': '0G', 'KRW-MANTRA': 'MANTRA OM',
|
||||||
|
'KRW-EDGE': 'EDGE', 'KRW-CFG': 'Centrifuge', 'KRW-ARDR': 'Ardor',
|
||||||
|
'KRW-SIGN': 'SIGN', 'KRW-AZTEC': 'AZTEC', 'KRW-ATH': 'Aethir',
|
||||||
|
'KRW-HOLO': 'Holochain', 'KRW-BREV': 'BREV', 'KRW-SHIB': 'Shiba Inu',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
|
||||||
|
dsn=os.environ["ORACLE_DSN"])
|
||||||
|
if w := os.environ.get("ORACLE_WALLET"):
|
||||||
|
kwargs["config_dir"] = w
|
||||||
|
return oracledb.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_context(conn, ticker: str, context_type: str, content: str):
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""MERGE INTO ticker_context tc
|
||||||
|
USING (SELECT :t AS ticker, :ct AS context_type FROM dual) s
|
||||||
|
ON (tc.ticker = s.ticker AND tc.context_type = s.context_type)
|
||||||
|
WHEN MATCHED THEN
|
||||||
|
UPDATE SET content = :c, updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ticker, context_type, content)
|
||||||
|
VALUES (:t, :ct, :c)""",
|
||||||
|
{'t': ticker, 'ct': context_type, 'c': content},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_price_stats(conn):
|
||||||
|
"""각 종목의 24h/7d 가격 변동률 + 거래량 추이를 수집."""
|
||||||
|
log.info("[price_stats] 수집 시작")
|
||||||
|
count = 0
|
||||||
|
for ticker in TICKERS:
|
||||||
|
try:
|
||||||
|
# 일봉 7개 (7일치)
|
||||||
|
df_day = pyupbit.get_ohlcv(ticker, interval='day', count=8)
|
||||||
|
if df_day is None or len(df_day) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
today = df_day.iloc[-1]
|
||||||
|
yesterday = df_day.iloc[-2]
|
||||||
|
|
||||||
|
# 24h 변동률
|
||||||
|
chg_24h = (today['close'] - yesterday['close']) / yesterday['close'] * 100
|
||||||
|
|
||||||
|
# 7d 변동률
|
||||||
|
if len(df_day) >= 8:
|
||||||
|
week_ago = df_day.iloc[-8]
|
||||||
|
chg_7d = (today['close'] - week_ago['close']) / week_ago['close'] * 100
|
||||||
|
else:
|
||||||
|
chg_7d = None
|
||||||
|
|
||||||
|
# 거래량 추이 (최근 3일 vs 이전 3일)
|
||||||
|
if len(df_day) >= 7:
|
||||||
|
recent_vol = df_day['volume'].iloc[-3:].mean()
|
||||||
|
prev_vol = df_day['volume'].iloc[-6:-3].mean()
|
||||||
|
vol_trend = recent_vol / prev_vol if prev_vol > 0 else 1.0
|
||||||
|
else:
|
||||||
|
vol_trend = None
|
||||||
|
|
||||||
|
# 1시간봉 최근 24개 (24시간)
|
||||||
|
df_h1 = pyupbit.get_ohlcv(ticker, interval='minute60', count=24)
|
||||||
|
h1_high = h1_low = None
|
||||||
|
if df_h1 is not None and not df_h1.empty:
|
||||||
|
h1_high = float(df_h1['high'].max())
|
||||||
|
h1_low = float(df_h1['low'].min())
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'price': float(today['close']),
|
||||||
|
'chg_24h_pct': round(chg_24h, 2),
|
||||||
|
'chg_7d_pct': round(chg_7d, 2) if chg_7d is not None else None,
|
||||||
|
'vol_today': float(today['volume']),
|
||||||
|
'vol_trend_3d': round(vol_trend, 2) if vol_trend is not None else None,
|
||||||
|
'h24_high': h1_high,
|
||||||
|
'h24_low': h1_low,
|
||||||
|
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||||
|
}
|
||||||
|
upsert_context(conn, ticker, 'price_stats', json.dumps(stats, ensure_ascii=False))
|
||||||
|
count += 1
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[price_stats] {ticker} 오류: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[price_stats] 완료 {count}/{len(TICKERS)} 종목")
|
||||||
|
|
||||||
|
|
||||||
|
def search_news(coin_name: str, max_results: int = 5) -> list[dict]:
|
||||||
|
"""SearXNG로 코인 뉴스 검색."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
SEARXNG_URL,
|
||||||
|
params={
|
||||||
|
'q': f'{coin_name} crypto news',
|
||||||
|
'format': 'json',
|
||||||
|
'categories': 'news',
|
||||||
|
'language': 'ko-KR',
|
||||||
|
'time_range': 'week',
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
for r in data.get('results', [])[:max_results]:
|
||||||
|
results.append({
|
||||||
|
'title': r.get('title', ''),
|
||||||
|
'url': r.get('url', ''),
|
||||||
|
'content': r.get('content', '')[:200],
|
||||||
|
'date': r.get('publishedDate', ''),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[news] {coin_name} 검색 오류: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def collect_news(conn):
|
||||||
|
"""각 종목의 최근 뉴스를 수집."""
|
||||||
|
log.info("[news] 수집 시작")
|
||||||
|
count = 0
|
||||||
|
for ticker in TICKERS:
|
||||||
|
coin_name = COIN_NAMES.get(ticker, ticker.split('-')[1])
|
||||||
|
try:
|
||||||
|
articles = search_news(coin_name)
|
||||||
|
if not articles:
|
||||||
|
continue
|
||||||
|
|
||||||
|
news_data = {
|
||||||
|
'coin': coin_name,
|
||||||
|
'articles': articles,
|
||||||
|
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||||
|
}
|
||||||
|
upsert_context(conn, ticker, 'news', json.dumps(news_data, ensure_ascii=False))
|
||||||
|
count += 1
|
||||||
|
time.sleep(1) # 검색 API rate limit 배려
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[news] {ticker} 오류: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[news] 완료 {count}/{len(TICKERS)} 종목")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=== context_collector 시작 (1시간 간격) ===")
|
||||||
|
log.info(f"대상: {len(TICKERS)}개 종목")
|
||||||
|
conn = get_conn()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
collect_price_stats(conn)
|
||||||
|
collect_news(conn)
|
||||||
|
except oracledb.DatabaseError as e:
|
||||||
|
log.error(f"DB 오류: {e} — 재연결")
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
conn = get_conn()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"오류: {e}")
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
log.info(f"[완료] {elapsed:.0f}초 소요, {COLLECT_INTERVAL}초 후 다음 수집")
|
||||||
|
time.sleep(max(60, COLLECT_INTERVAL - elapsed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
169
daemons/fetch_1min_history.py
Normal file
169
daemons/fetch_1min_history.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""1분봉 장기 히스토리 fetch 데몬.
|
||||||
|
|
||||||
|
주요 5종목(BTC/ETH/XRP/SOL/DOGE)의 1분봉을 2년치까지 소급 수집.
|
||||||
|
백그라운드에서 조용히 실행 — API 딜레이 충분히 줘서 다른 작업 방해 안 함.
|
||||||
|
재시작 시 DB에 이미 있는 범위는 건너뜀.
|
||||||
|
|
||||||
|
실행:
|
||||||
|
.venv/bin/python3 daemons/fetch_1min_history.py [--tickers BTC ETH] [--days 730]
|
||||||
|
로그:
|
||||||
|
/tmp/fetch_1min_history.log
|
||||||
|
"""
|
||||||
|
import sys, os, time, argparse, logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
# ── 설정 ──────────────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_TICKERS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE']
|
||||||
|
BATCH = 200 # API 1회 요청 봉수 (Upbit 최대 200)
|
||||||
|
DELAY = 0.4 # API 호출 간격 (초) — 넉넉히 줘서 rate limit 회피
|
||||||
|
RETRY_WAIT = 5.0 # 오류 시 대기 (초)
|
||||||
|
|
||||||
|
# ── 로깅 설정 ─────────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/tmp/fetch_1min_history.log'),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn():
|
||||||
|
kwargs = dict(user=os.environ["ORACLE_USER"],
|
||||||
|
password=os.environ["ORACLE_PASSWORD"],
|
||||||
|
dsn=os.environ["ORACLE_DSN"])
|
||||||
|
wallet = os.environ.get("ORACLE_WALLET")
|
||||||
|
if wallet:
|
||||||
|
kwargs["config_dir"] = wallet
|
||||||
|
return oracledb.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oldest_ts(conn, ticker: str):
|
||||||
|
"""DB에 있는 해당 ticker 1분봉의 가장 오래된 ts 반환. 없으면 None."""
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT MIN(ts) FROM backtest_ohlcv "
|
||||||
|
"WHERE ticker=:t AND interval_cd='minute1'",
|
||||||
|
{"t": ticker}
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def insert_batch(conn, ticker: str, rows: list) -> int:
|
||||||
|
"""rows: [(ts, open, high, low, close, volume), ...] — bulk insert, 중복 무시."""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO backtest_ohlcv "
|
||||||
|
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
|
||||||
|
"VALUES (:1,'minute1',:2,:3,:4,:5,:6,:7)",
|
||||||
|
[(ticker, ts, o, h, l, c, v) for ts, o, h, l, c, v in rows],
|
||||||
|
batcherrors=True,
|
||||||
|
)
|
||||||
|
errors = cur.getbatcherrors()
|
||||||
|
conn.commit()
|
||||||
|
return len(rows) - len(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_ticker(conn, ticker: str, cutoff: datetime) -> int:
|
||||||
|
"""ticker의 cutoff까지 1분봉 소급 fetch.
|
||||||
|
DB에 이미 있는 범위는 batcherrors로 자동 스킵.
|
||||||
|
"""
|
||||||
|
oldest_in_db = get_oldest_ts(conn, ticker)
|
||||||
|
if oldest_in_db and oldest_in_db <= cutoff:
|
||||||
|
log.info(f"{ticker}: DB에 이미 {oldest_in_db.date()} 까지 있음 → 스킵")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# datetime.now()에서 시작해 cutoff까지 역방향 fetch
|
||||||
|
# 중복은 DB unique constraint + batcherrors가 처리
|
||||||
|
to_dt = datetime.now()
|
||||||
|
total = 0
|
||||||
|
batch_n = 0
|
||||||
|
|
||||||
|
if oldest_in_db:
|
||||||
|
log.info(f"{ticker}: DB 최솟값={oldest_in_db.date()}, {cutoff.date()} 까지 소급 시작")
|
||||||
|
else:
|
||||||
|
log.info(f"{ticker}: DB에 데이터 없음, {cutoff.date()} 까지 전체 fetch 시작")
|
||||||
|
|
||||||
|
while to_dt > cutoff:
|
||||||
|
# pyupbit의 to 파라미터는 UTC로 해석됨 — KST에서 9시간 빼서 전달
|
||||||
|
to_utc = to_dt - timedelta(hours=9)
|
||||||
|
to_str = to_utc.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
|
||||||
|
time.sleep(DELAY)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"{ticker} API 오류: {e} → {RETRY_WAIT}s 후 재시도")
|
||||||
|
time.sleep(RETRY_WAIT)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df is None or len(df) == 0:
|
||||||
|
log.info(f"{ticker}: API 데이터 소진 ({to_str})")
|
||||||
|
break
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(ts.to_pydatetime(), float(r['open']), float(r['high']),
|
||||||
|
float(r['low']), float(r['close']), float(r['volume']))
|
||||||
|
for ts, r in df.iterrows()
|
||||||
|
]
|
||||||
|
n = insert_batch(conn, ticker, rows)
|
||||||
|
total += n
|
||||||
|
batch_n += 1
|
||||||
|
oldest = df.index[0].to_pydatetime()
|
||||||
|
|
||||||
|
if batch_n % 50 == 0:
|
||||||
|
log.info(f" {ticker} 배치{batch_n:04d}: {oldest.date()} | 신규 누적 {total:,}행")
|
||||||
|
|
||||||
|
to_dt = oldest - timedelta(minutes=1)
|
||||||
|
|
||||||
|
if oldest <= cutoff:
|
||||||
|
break
|
||||||
|
|
||||||
|
log.info(f"{ticker}: 완료 — 신규 {total:,}행 (배치 {batch_n}회)")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--tickers', nargs='+', default=DEFAULT_TICKERS,
|
||||||
|
help='수집 티커 (예: KRW-BTC KRW-ETH)')
|
||||||
|
parser.add_argument('--days', type=int, default=730,
|
||||||
|
help='소급 일수 (기본 730일 = 2년)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cutoff = datetime.now() - timedelta(days=args.days)
|
||||||
|
log.info(f"=== 1분봉 히스토리 데몬 시작 ===")
|
||||||
|
log.info(f"대상: {args.tickers}")
|
||||||
|
log.info(f"목표: {cutoff.date()} ({args.days}일) 까지 소급")
|
||||||
|
|
||||||
|
conn = _get_conn()
|
||||||
|
grand_total = 0
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
for ticker in args.tickers:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
n = fetch_ticker(conn, ticker, cutoff)
|
||||||
|
grand_total += n
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
log.info(f"{ticker}: {n:,}행 저장 ({elapsed/60:.1f}분)")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"{ticker}: 오류 — {e}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
total_min = (time.time() - t_start) / 60
|
||||||
|
log.info(f"=== 완료: 총 {grand_total:,}행 / {total_min:.0f}분 ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
363
daemons/live_trader.py
Normal file
363
daemons/live_trader.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""실시간 1분봉 볼륨 가속 트레이더.
|
||||||
|
|
||||||
|
4봉 연속 가격+볼륨 가속 시그널(VOL≥8x) 감지 후 실제 매수/매도 + Telegram 알림.
|
||||||
|
|
||||||
|
실행:
|
||||||
|
.venv/bin/python3 daemons/live_trader.py
|
||||||
|
로그:
|
||||||
|
/tmp/live_trader.log
|
||||||
|
"""
|
||||||
|
import sys, os, time, logging, requests
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
|
||||||
|
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
||||||
|
TICKERS = [
|
||||||
|
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
|
||||||
|
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
|
||||||
|
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
|
||||||
|
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
|
||||||
|
]
|
||||||
|
VOL_LOOKBACK = 61
|
||||||
|
ATR_LOOKBACK = 28
|
||||||
|
FETCH_BARS = 100
|
||||||
|
VOL_MIN = 8.0
|
||||||
|
ATR_MULT = 1.0
|
||||||
|
ATR_MIN_R = 0.030 # 3.0% (ATR 계산용, ⑤ trail에서는 미사용)
|
||||||
|
ATR_MAX_R = 0.050 # 5.0%
|
||||||
|
|
||||||
|
# ── Cascade 청산 파라미터 ──────────────────────────────────────────────────────
|
||||||
|
# (시작분, 종료분, limit 수익률)
|
||||||
|
CASCADE_STAGES = [
|
||||||
|
(0, 2, 0.020), # ① 0~ 2분: 현재가 >= 진입가×1.020 → 청산
|
||||||
|
(2, 5, 0.010), # ② 2~ 5분: 현재가 >= 진입가×1.010 → 청산
|
||||||
|
(5, 35, 0.005), # ③ 5~35분: 현재가 >= 진입가×1.005 → 청산
|
||||||
|
(35, 155, 0.001), # ④ 35~155분: 현재가 >= 진입가×1.001 → 청산 (본전)
|
||||||
|
]
|
||||||
|
TRAIL_STOP_R = 0.004 # ⑤ 155분~: Trail Stop 0.4%
|
||||||
|
|
||||||
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
||||||
|
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
|
||||||
|
FEE = 0.0005
|
||||||
|
|
||||||
|
POLL_SEC = 65
|
||||||
|
API_DELAY = 0.12
|
||||||
|
TIMEOUT_BARS = 240 # 4시간: ⑤ Trail 구간에서 본전 이하 시 청산
|
||||||
|
|
||||||
|
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# ── Upbit 클라이언트 ───────────────────────────────────────────────────────────
|
||||||
|
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
|
||||||
|
|
||||||
|
# ── Telegram ──────────────────────────────────────────────────────────────────
|
||||||
|
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
|
||||||
|
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
||||||
|
|
||||||
|
def tg(msg: str) -> None:
|
||||||
|
if not TG_TOKEN or not TG_CHAT_ID:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
|
||||||
|
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'Telegram 전송 실패: {e}')
|
||||||
|
|
||||||
|
# ── 로깅 ──────────────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/tmp/live_trader.log'),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||||||
|
def compute_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
vol_ma = df['volume'].rolling(VOL_LOOKBACK, min_periods=30).mean().shift(2)
|
||||||
|
df = df.copy()
|
||||||
|
df['vr'] = df['volume'] / vol_ma
|
||||||
|
|
||||||
|
prev_close = df['close'].shift(1)
|
||||||
|
tr = pd.concat([
|
||||||
|
df['high'] - df['low'],
|
||||||
|
(df['high'] - prev_close).abs(),
|
||||||
|
(df['low'] - prev_close).abs(),
|
||||||
|
], axis=1).max(axis=1)
|
||||||
|
df['atr_raw'] = tr.rolling(ATR_LOOKBACK, min_periods=10).mean() / prev_close
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def check_signal(df: pd.DataFrame) -> Optional[dict]:
|
||||||
|
"""마지막 3봉(완성봉) 가격+볼륨 가속 조건 체크."""
|
||||||
|
if len(df) < VOL_LOOKBACK + 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
b = df.iloc[-4:-1] # 완성된 마지막 3봉
|
||||||
|
if len(b) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
c = b['close'].values
|
||||||
|
o = b['open'].values
|
||||||
|
vr = b['vr'].values
|
||||||
|
|
||||||
|
if not all(c[i] > o[i] for i in range(3)): return None # 양봉
|
||||||
|
if not (c[2] > c[1] > c[0]): return None # 가격 가속
|
||||||
|
if not (vr[2] > vr[1] > vr[0]): return None # 볼륨 가속
|
||||||
|
if vr[2] < VOL_MIN: return None # VOL 임계값
|
||||||
|
|
||||||
|
atr_raw = float(b['atr_raw'].iloc[-1]) if not pd.isna(b['atr_raw'].iloc[-1]) else 0.0
|
||||||
|
return {
|
||||||
|
'sig_ts': b.index[-1],
|
||||||
|
'sig_price': float(c[2]),
|
||||||
|
'vr': list(vr),
|
||||||
|
'prices': list(c),
|
||||||
|
'atr_raw': atr_raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
||||||
|
def do_buy(ticker: str, krw_amount: int) -> Optional[float]:
|
||||||
|
"""시장가 매수. 실제 체결 수량 반환. 실패 시 None."""
|
||||||
|
if SIM_MODE:
|
||||||
|
current = pyupbit.get_current_price(ticker)
|
||||||
|
qty = krw_amount * (1 - FEE) / current
|
||||||
|
log.info(f"[SIM 매수] {ticker} {krw_amount:,}원 → {qty:.6f}개 @ {current:,.0f}")
|
||||||
|
return qty
|
||||||
|
|
||||||
|
try:
|
||||||
|
krw_bal = upbit.get_balance("KRW")
|
||||||
|
if krw_bal is None or krw_bal < krw_amount:
|
||||||
|
log.warning(f"KRW 잔고 부족: {krw_bal:,.0f}원 < {krw_amount:,}원")
|
||||||
|
return None
|
||||||
|
|
||||||
|
order = upbit.buy_market_order(ticker, krw_amount)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"매수 주문 실패: {order}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 체결 대기 후 실제 보유량 조회
|
||||||
|
time.sleep(1.5)
|
||||||
|
coin = ticker.split('-')[1]
|
||||||
|
qty = upbit.get_balance(coin)
|
||||||
|
log.info(f"[매수 완료] {ticker} {krw_amount:,}원 → {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
|
||||||
|
return qty if qty and qty > 0 else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"매수 오류 {ticker}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def do_sell(ticker: str, qty: float) -> Optional[float]:
|
||||||
|
"""시장가 매도. 체결가(추정) 반환. 실패 시 None."""
|
||||||
|
if SIM_MODE:
|
||||||
|
current = pyupbit.get_current_price(ticker)
|
||||||
|
log.info(f"[SIM 매도] {ticker} {qty:.6f}개 @ {current:,.0f}")
|
||||||
|
return current
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = upbit.sell_market_order(ticker, qty)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"매도 주문 실패: {order}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
time.sleep(1.5)
|
||||||
|
current = pyupbit.get_current_price(ticker)
|
||||||
|
log.info(f"[매도 완료] {ticker} {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
|
||||||
|
return current
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"매도 오류 {ticker}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
||||||
|
positions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def enter_position(ticker: str, sig: dict, entry_price: float) -> None:
|
||||||
|
ar = sig['atr_raw']
|
||||||
|
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||||
|
|
||||||
|
qty = do_buy(ticker, PER_POS)
|
||||||
|
if qty is None:
|
||||||
|
log.warning(f"[진입 실패] {ticker} — 매수 주문 오류")
|
||||||
|
return
|
||||||
|
|
||||||
|
positions[ticker] = {
|
||||||
|
'entry_price': entry_price,
|
||||||
|
'entry_ts': datetime.now(),
|
||||||
|
'running_peak': entry_price,
|
||||||
|
'qty': qty,
|
||||||
|
'vr': sig['vr'],
|
||||||
|
'prices': sig['prices'],
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {sig['vr'][2]:.1f}x cascade①2%②1%③0.5%④0.1%⑤trail0.4%")
|
||||||
|
tg(
|
||||||
|
f"🟢 <b>매수 완료</b> {ticker}\n"
|
||||||
|
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
|
||||||
|
f"전략: ①2% ②1% ③0.5% ④0.1%(본전) ⑤Trail0.4%\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_exit(ticker: str, current_price: float, reason: str) -> bool:
|
||||||
|
"""공통 청산 처리. reason: 'trail' | 'timeout'"""
|
||||||
|
pos = positions[ticker]
|
||||||
|
exit_price = do_sell(ticker, pos['qty'])
|
||||||
|
if exit_price is None:
|
||||||
|
exit_price = current_price
|
||||||
|
|
||||||
|
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
|
||||||
|
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
|
||||||
|
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
|
||||||
|
|
||||||
|
icon = "✅" if pnl > 0 else "🔴"
|
||||||
|
reason_tag = {
|
||||||
|
'①2%': '① +2.0% 익절',
|
||||||
|
'②1%': '② +1.0% 익절',
|
||||||
|
'③0.5%': '③ +0.5% 익절',
|
||||||
|
'④0.1%': '④ +0.1% 본전',
|
||||||
|
'⑤trail': '⑤ 트레일스탑',
|
||||||
|
'timeout': '⑤ 타임아웃',
|
||||||
|
}.get(reason, reason)
|
||||||
|
msg = (
|
||||||
|
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
||||||
|
f"진입: {pos['entry_price']:,.0f}원\n"
|
||||||
|
f"고점: {pos['running_peak']:,.0f}원 ({(pos['running_peak']/pos['entry_price']-1)*100:+.2f}%)\n"
|
||||||
|
f"청산: {exit_price:,.0f}원\n"
|
||||||
|
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}분 보유\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
f"[청산/{reason}] {ticker} {exit_price:,.0f}원 "
|
||||||
|
f"PNL {pnl:+.2f}% {krw:+,.0f}원 {held}분 보유"
|
||||||
|
)
|
||||||
|
tg(msg)
|
||||||
|
del positions[ticker]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def update_position(ticker: str, current_price: float) -> bool:
|
||||||
|
"""Cascade 청산 체크. 청산 시 True 반환.
|
||||||
|
|
||||||
|
① 0~ 2분: +2.0% limit
|
||||||
|
② 2~ 5분: +1.0% limit
|
||||||
|
③ 5~35분: +0.5% limit
|
||||||
|
④ 35~155분: +0.1% limit (본전)
|
||||||
|
⑤ 155분~: Trail Stop 0.4%
|
||||||
|
"""
|
||||||
|
pos = positions[ticker]
|
||||||
|
ep = pos['entry_price']
|
||||||
|
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
|
||||||
|
|
||||||
|
# 항상 고점 갱신 (⑤ trail 진입 시 정확한 고점 기준)
|
||||||
|
pos['running_peak'] = max(pos['running_peak'], current_price)
|
||||||
|
|
||||||
|
# ①②③④: cascade limit 단계
|
||||||
|
stage_labels = {0: '①2%', 2: '②1%', 5: '③0.5%', 35: '④0.1%'}
|
||||||
|
for start, end, lr in CASCADE_STAGES:
|
||||||
|
if start <= held < end:
|
||||||
|
if current_price >= ep * (1 + lr):
|
||||||
|
return _do_exit(ticker, current_price, stage_labels[start])
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ⑤: Trail Stop 0.4%
|
||||||
|
drop = (pos['running_peak'] - current_price) / pos['running_peak']
|
||||||
|
if drop >= TRAIL_STOP_R:
|
||||||
|
return _do_exit(ticker, current_price, '⑤trail')
|
||||||
|
|
||||||
|
# 타임아웃: 4시간 경과 + 본전 이하
|
||||||
|
if held >= TIMEOUT_BARS and current_price <= ep:
|
||||||
|
return _do_exit(ticker, current_price, 'timeout')
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메인 루프 ─────────────────────────────────────────────────────────────────
|
||||||
|
def run_once() -> None:
|
||||||
|
for ticker in TICKERS:
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=FETCH_BARS)
|
||||||
|
time.sleep(API_DELAY)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"{ticker} API 오류: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if df is None or len(df) < 30:
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = compute_indicators(df)
|
||||||
|
current_price = float(df['close'].iloc[-1])
|
||||||
|
|
||||||
|
# 열린 포지션: trail stop 체크
|
||||||
|
if ticker in positions:
|
||||||
|
update_position(ticker, current_price)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 신규 진입: 시그널 체크 (슬롯 여부와 관계없이 항상 탐지)
|
||||||
|
sig = check_signal(df)
|
||||||
|
if sig:
|
||||||
|
vr = sig['vr']
|
||||||
|
pr = sig['prices']
|
||||||
|
slot_tag = f"→ 매수 진행" if len(positions) < MAX_POS else f"⚠️ 슬롯 {len(positions)}/{MAX_POS} 꽉 참"
|
||||||
|
# 시그널 감지 즉시 알림
|
||||||
|
tg(
|
||||||
|
f"🔔 <b>시그널</b> {ticker}\n"
|
||||||
|
f"가격: {pr[0]:,.0f}→{pr[1]:,.0f}→{pr[2]:,.0f}\n"
|
||||||
|
f"볼륨: {vr[0]:.1f}x→{vr[1]:.1f}x→{vr[2]:.1f}x\n"
|
||||||
|
f"현재가: {current_price:,.0f}원 ATR: {sig['atr_raw']*100:.2f}%\n"
|
||||||
|
f"{slot_tag}"
|
||||||
|
)
|
||||||
|
log.info(f"[시그널] {ticker} {current_price:,.0f}원 vol {vr[2]:.1f}x {slot_tag}")
|
||||||
|
if len(positions) < MAX_POS:
|
||||||
|
enter_position(ticker, sig, current_price)
|
||||||
|
|
||||||
|
pos_str = ', '.join(
|
||||||
|
f"{t}({p['entry_price']:,.0f}→{p['running_peak']:,.0f}, {((p['running_peak']/p['entry_price'])-1)*100:+.1f}%)"
|
||||||
|
for t, p in positions.items()
|
||||||
|
) or "없음"
|
||||||
|
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS}: {pos_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
||||||
|
log.info(f"=== 실시간 트레이더 시작 ({mode}) ===")
|
||||||
|
log.info(f"전략: 3봉 vol가속 VOL≥{VOL_MIN}x, cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%")
|
||||||
|
log.info(f"종목: {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}개")
|
||||||
|
|
||||||
|
tg(
|
||||||
|
f"🚀 <b>트레이더 시작</b> ({mode})\n"
|
||||||
|
f"3봉 VOL≥{VOL_MIN}x cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%\n"
|
||||||
|
f"종목 {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}개"
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
run_once()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"루프 오류: {e}")
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
sleep = max(1.0, POLL_SEC - elapsed)
|
||||||
|
log.info(f"[대기] {sleep:.0f}초 후 다음 체크")
|
||||||
|
time.sleep(sleep)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
118
daemons/tick_collector.py
Normal file
118
daemons/tick_collector.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""30초마다 전 종목 현재가를 Oracle price_tick 테이블에 적재.
|
||||||
|
+ 60초마다 backtest_ohlcv 1분봉 최신 데이터 갱신.
|
||||||
|
"""
|
||||||
|
import sys, os, time, logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
TICKERS = [
|
||||||
|
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
|
||||||
|
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
|
||||||
|
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
|
||||||
|
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
|
||||||
|
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
|
||||||
|
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
|
||||||
|
]
|
||||||
|
INTERVAL = 30 # 초
|
||||||
|
OHLCV_INTERVAL = 60 # 1분봉 갱신 주기 (초)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/tmp/tick_collector.log'),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
|
||||||
|
dsn=os.environ["ORACLE_DSN"])
|
||||||
|
if w := os.environ.get("ORACLE_WALLET"):
|
||||||
|
kwargs["config_dir"] = w
|
||||||
|
return oracledb.connect(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(conn):
|
||||||
|
prices = pyupbit.get_current_price(TICKERS)
|
||||||
|
if not prices:
|
||||||
|
log.warning("현재가 조회 실패")
|
||||||
|
return
|
||||||
|
|
||||||
|
ts = datetime.now().replace(microsecond=0)
|
||||||
|
rows = [(t, ts, p) for t, p in prices.items() if p]
|
||||||
|
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO price_tick (ticker, ts, price) VALUES (:1, :2, :3)",
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"적재 {len(rows)}건 ts={ts}")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_ohlcv(conn):
|
||||||
|
"""backtest_ohlcv 1분봉을 최근 5개씩 갱신 (중복 무시)."""
|
||||||
|
total = 0
|
||||||
|
for ticker in TICKERS:
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=5)
|
||||||
|
if df is None or df.empty:
|
||||||
|
continue
|
||||||
|
rows = [
|
||||||
|
(ticker, 'minute1', ts.to_pydatetime(),
|
||||||
|
float(r['open']), float(r['high']), float(r['low']),
|
||||||
|
float(r['close']), float(r['volume']))
|
||||||
|
for ts, r in df.iterrows()
|
||||||
|
]
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO backtest_ohlcv "
|
||||||
|
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
|
||||||
|
"VALUES (:1,:2,:3,:4,:5,:6,:7,:8)",
|
||||||
|
rows, batcherrors=True,
|
||||||
|
)
|
||||||
|
inserted = len(rows) - len(cur.getbatcherrors())
|
||||||
|
total += inserted
|
||||||
|
time.sleep(0.15)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[ohlcv] {ticker} 오류: {e}")
|
||||||
|
conn.commit()
|
||||||
|
if total > 0:
|
||||||
|
log.info(f"[ohlcv] 1분봉 갱신 {total}건")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=== tick_collector 시작 (30초 간격 + 1분봉 갱신) ===")
|
||||||
|
conn = get_conn()
|
||||||
|
last_ohlcv = 0
|
||||||
|
while True:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
collect(conn)
|
||||||
|
# 1분봉 갱신 (OHLCV_INTERVAL마다)
|
||||||
|
if t0 - last_ohlcv >= OHLCV_INTERVAL:
|
||||||
|
refresh_ohlcv(conn)
|
||||||
|
last_ohlcv = t0
|
||||||
|
except oracledb.DatabaseError as e:
|
||||||
|
log.error(f"DB 오류: {e} — 재연결")
|
||||||
|
try: conn.close()
|
||||||
|
except: pass
|
||||||
|
conn = get_conn()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"오류: {e}")
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
time.sleep(max(1.0, INTERVAL - elapsed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
802
daemons/tick_trader.py
Normal file
802
daemons/tick_trader.py
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
"""WebSocket 기반 20초봉 트레이더.
|
||||||
|
|
||||||
|
구조:
|
||||||
|
WebSocket -> trade tick 수신 -> 20초봉 집계
|
||||||
|
-> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
|
||||||
|
-> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
.venv/bin/python3 daemons/tick_trader.py
|
||||||
|
로그:
|
||||||
|
/tmp/tick_trader.log
|
||||||
|
"""
|
||||||
|
import sys, os, time, logging, threading, requests, math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import deque, defaultdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
|
from core.llm_advisor import get_exit_price, get_entry_price
|
||||||
|
|
||||||
|
import pyupbit
|
||||||
|
import oracledb
|
||||||
|
|
||||||
|
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
||||||
|
TICKERS = [
|
||||||
|
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
|
||||||
|
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
||||||
|
]
|
||||||
|
|
||||||
|
BAR_SEC = 20 # 봉 주기 (초)
|
||||||
|
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
||||||
|
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
||||||
|
VOL_MIN = 5.0 # 거래량 배수 임계값
|
||||||
|
VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터
|
||||||
|
BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초)
|
||||||
|
|
||||||
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
|
||||||
|
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
|
||||||
|
PER_POS = MAX_BUDGET // MAX_POS
|
||||||
|
FEE = 0.0005
|
||||||
|
|
||||||
|
# 트레일링 스탑 청산
|
||||||
|
TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도
|
||||||
|
MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5%
|
||||||
|
STOP_LOSS_PCT = 0.02 # -2% 손절
|
||||||
|
TIMEOUT_SECS = 14400 # 4시간
|
||||||
|
|
||||||
|
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
|
||||||
|
|
||||||
|
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
|
||||||
|
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
||||||
|
|
||||||
|
# ── 로깅 ──────────────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/tmp/tick_trader.log'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── position_sync DB ─────────────────────────────────────────────────────────
|
||||||
|
_db_conn = None
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
global _db_conn
|
||||||
|
if _db_conn is None:
|
||||||
|
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
|
||||||
|
dsn=os.environ["ORACLE_DSN"])
|
||||||
|
if w := os.environ.get("ORACLE_WALLET"):
|
||||||
|
kwargs["config_dir"] = w
|
||||||
|
_db_conn = oracledb.connect(**kwargs)
|
||||||
|
return _db_conn
|
||||||
|
|
||||||
|
|
||||||
|
def sync_position(ticker: str, state: str, buy_price=None, sell_price=None,
|
||||||
|
qty=None, order_uuid=None, invested_krw=None):
|
||||||
|
"""position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신."""
|
||||||
|
try:
|
||||||
|
conn = _get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
if state == 'IDLE':
|
||||||
|
cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker])
|
||||||
|
else:
|
||||||
|
now = datetime.now()
|
||||||
|
cur.execute(
|
||||||
|
"""MERGE INTO position_sync ps
|
||||||
|
USING (SELECT :1 AS ticker FROM dual) src
|
||||||
|
ON (ps.ticker = src.ticker)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
state = :2, buy_price = :3, sell_price = :4,
|
||||||
|
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
|
||||||
|
WHEN NOT MATCHED THEN INSERT
|
||||||
|
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
|
||||||
|
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
|
||||||
|
[ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now,
|
||||||
|
ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now])
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
|
||||||
|
global _db_conn
|
||||||
|
_db_conn = None
|
||||||
|
|
||||||
|
|
||||||
|
def fp(price: float) -> str:
|
||||||
|
"""가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시."""
|
||||||
|
if price >= 100:
|
||||||
|
return f"{price:,.0f}"
|
||||||
|
elif price >= 10:
|
||||||
|
return f"{price:,.1f}"
|
||||||
|
else:
|
||||||
|
return f"{price:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def tg(msg: str) -> None:
|
||||||
|
if not TG_TOKEN or not TG_CHAT_ID:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
|
||||||
|
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'Telegram 전송 실패: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
||||||
|
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
||||||
|
cur_bar: dict = {}
|
||||||
|
bar_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
|
||||||
|
return {'open': price, 'high': price, 'low': price,
|
||||||
|
'close': price, 'volume': volume, 'ts': ts}
|
||||||
|
|
||||||
|
|
||||||
|
def on_tick(ticker: str, price: float, volume: float) -> None:
|
||||||
|
with bar_lock:
|
||||||
|
if ticker not in cur_bar:
|
||||||
|
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
|
||||||
|
return
|
||||||
|
b = cur_bar[ticker]
|
||||||
|
b['high'] = max(b['high'], price)
|
||||||
|
b['low'] = min(b['low'], price)
|
||||||
|
b['close'] = price
|
||||||
|
b['volume'] += volume
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_bars() -> None:
|
||||||
|
"""BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인."""
|
||||||
|
while True:
|
||||||
|
time.sleep(BAR_SEC)
|
||||||
|
now = datetime.now()
|
||||||
|
signals = []
|
||||||
|
with bar_lock:
|
||||||
|
for ticker in list(cur_bar.keys()):
|
||||||
|
b = cur_bar[ticker]
|
||||||
|
if b['volume'] == 0:
|
||||||
|
continue
|
||||||
|
bars[ticker].append(b)
|
||||||
|
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
||||||
|
sig = detect_signal(ticker)
|
||||||
|
if sig:
|
||||||
|
signals.append(sig)
|
||||||
|
# bar_lock 밖에서 LLM 호출 + 체결 확인
|
||||||
|
for sig in signals:
|
||||||
|
process_signal(sig)
|
||||||
|
check_pending_buys()
|
||||||
|
check_filled_positions()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||||||
|
def calc_vr(bar_list: list, idx: int) -> float:
|
||||||
|
start = max(0, idx - VOL_LOOKBACK)
|
||||||
|
end = max(0, idx - 2)
|
||||||
|
baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
|
||||||
|
if not baseline:
|
||||||
|
return 0.0
|
||||||
|
# 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지
|
||||||
|
trim = max(1, len(baseline) // 10)
|
||||||
|
trimmed = baseline[:len(baseline) - trim]
|
||||||
|
if not trimmed:
|
||||||
|
return 0.0
|
||||||
|
avg = sum(trimmed) / len(trimmed)
|
||||||
|
return bar_list[idx]['volume'] / avg if avg > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def calc_atr(bar_list: list) -> float:
|
||||||
|
if len(bar_list) < ATR_LOOKBACK + 2:
|
||||||
|
return 0.0
|
||||||
|
trs = []
|
||||||
|
for i in range(-ATR_LOOKBACK - 1, -1):
|
||||||
|
b = bar_list[i]
|
||||||
|
bp = bar_list[i - 1]
|
||||||
|
tr = max(b['high'] - b['low'],
|
||||||
|
abs(b['high'] - bp['close']),
|
||||||
|
abs(b['low'] - bp['close']))
|
||||||
|
trs.append(tr)
|
||||||
|
prev_close = bar_list[-2]['close']
|
||||||
|
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── 시그널 감지 (완화 — LLM이 최종 판단) ────────────────────────────────────
|
||||||
|
def detect_signal(ticker: str) -> Optional[dict]:
|
||||||
|
"""양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출."""
|
||||||
|
bar_list = list(bars[ticker])
|
||||||
|
n = len(bar_list)
|
||||||
|
|
||||||
|
if n < VOL_LOOKBACK + 5:
|
||||||
|
return None
|
||||||
|
if ticker in positions or ticker in pending_buys:
|
||||||
|
return None
|
||||||
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
b = bar_list[-1]
|
||||||
|
if b['close'] <= b['open']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vr = calc_vr(bar_list, n - 1)
|
||||||
|
if vr < VOL_MIN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 20초봉 거래대금 하드캡: 소량 조작 방지
|
||||||
|
bar_krw = b['close'] * b['volume']
|
||||||
|
if bar_krw < VOL_KRW_MIN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── LLM 호출 절감: skip 패턴 사전 필터 ──
|
||||||
|
|
||||||
|
# 1) 횡보 (최근 15봉 변동폭 < 0.3%) → 매수 매력 없음
|
||||||
|
recent = bar_list[-15:]
|
||||||
|
period_high = max(x['high'] for x in recent)
|
||||||
|
period_low = min(x['low'] for x in recent)
|
||||||
|
if period_low > 0:
|
||||||
|
spread_pct = (period_high - period_low) / period_low * 100
|
||||||
|
if spread_pct < 0.3:
|
||||||
|
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달)
|
||||||
|
long_bars = bar_list[-90:] # ~30분
|
||||||
|
long_high = max(x['high'] for x in long_bars)
|
||||||
|
long_low = min(x['low'] for x in long_bars)
|
||||||
|
if long_high > long_low:
|
||||||
|
pos_in_range = (b['close'] - long_low) / (long_high - long_low)
|
||||||
|
if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0:
|
||||||
|
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.1f}% → 스킵")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉이어야 진입
|
||||||
|
prev_greens = 0
|
||||||
|
for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1):
|
||||||
|
if bar_list[k]['close'] > bar_list[k]['open']:
|
||||||
|
prev_greens += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if prev_greens < 2:
|
||||||
|
log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 → 스킵")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ticker': ticker,
|
||||||
|
'price': b['close'],
|
||||||
|
'vol_ratio': vr,
|
||||||
|
'bar_list': bar_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
||||||
|
def _round_price(price: float) -> float:
|
||||||
|
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
|
||||||
|
if price >= 2_000_000: unit = 1000
|
||||||
|
elif price >= 1_000_000: unit = 500
|
||||||
|
elif price >= 100_000: unit = 100
|
||||||
|
elif price >= 10_000: unit = 10
|
||||||
|
elif price >= 1_000: unit = 5
|
||||||
|
elif price >= 100: unit = 1
|
||||||
|
elif price >= 10: unit = 0.1
|
||||||
|
else: unit = 0.01
|
||||||
|
return math.floor(price / unit) * unit
|
||||||
|
|
||||||
|
|
||||||
|
def submit_limit_sell(ticker: str, qty: float, price: float) -> Optional[str]:
|
||||||
|
"""지정가 매도 주문. Returns UUID."""
|
||||||
|
price = _round_price(price)
|
||||||
|
log.debug(f"[매도주문] {ticker} price={price} qty={qty}")
|
||||||
|
if SIM_MODE:
|
||||||
|
return f"sim-{ticker}"
|
||||||
|
try:
|
||||||
|
order = upbit_client.sell_limit_order(ticker, price, qty)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"지정가 매도 제출 실패 {ticker}: price={price} qty={qty} → {order}")
|
||||||
|
return None
|
||||||
|
return order.get('uuid')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"지정가 매도 오류 {ticker}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_order_safe(uuid: Optional[str]) -> None:
|
||||||
|
if SIM_MODE or not uuid or uuid.startswith('sim-'):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
upbit_client.cancel_order(uuid)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"주문 취소 실패 {uuid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_order_state(uuid: str) -> tuple:
|
||||||
|
"""Returns (state, avg_price). state: 'done'|'wait'|'cancel'|None"""
|
||||||
|
try:
|
||||||
|
detail = upbit_client.get_order(uuid)
|
||||||
|
if not detail:
|
||||||
|
return None, None
|
||||||
|
state = detail.get('state')
|
||||||
|
avg_price = float(detail.get('avg_price') or 0) or None
|
||||||
|
return state, avg_price
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"주문 조회 실패 {uuid}: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_price_from_order(uuid: str) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
detail = upbit_client.get_order(uuid)
|
||||||
|
if not detail:
|
||||||
|
return None
|
||||||
|
trades = detail.get('trades', [])
|
||||||
|
if trades:
|
||||||
|
total_funds = sum(float(t['funds']) for t in trades)
|
||||||
|
total_vol = sum(float(t['volume']) for t in trades)
|
||||||
|
return total_funds / total_vol if total_vol > 0 else None
|
||||||
|
avg = detail.get('avg_price')
|
||||||
|
return float(avg) if avg else None
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"체결가 조회 실패 {uuid}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def do_sell_market(ticker: str, qty: float) -> Optional[float]:
|
||||||
|
"""Trail Stop / Timeout용 시장가 매도."""
|
||||||
|
if SIM_MODE:
|
||||||
|
price = pyupbit.get_current_price(ticker)
|
||||||
|
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
|
||||||
|
return price
|
||||||
|
try:
|
||||||
|
order = upbit_client.sell_market_order(ticker, qty)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"시장가 매도 실패: {order}")
|
||||||
|
return None
|
||||||
|
uuid = order.get('uuid')
|
||||||
|
time.sleep(1.5)
|
||||||
|
avg_price = _avg_price_from_order(uuid) if uuid else None
|
||||||
|
return avg_price or pyupbit.get_current_price(ticker)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"시장가 매도 오류 {ticker}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 지정가 매수 (LLM 판단) ───────────────────────────────────────────────────
|
||||||
|
pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio}
|
||||||
|
|
||||||
|
|
||||||
|
def process_signal(sig: dict) -> None:
|
||||||
|
"""시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출."""
|
||||||
|
ticker = sig['ticker']
|
||||||
|
bar_list = sig['bar_list']
|
||||||
|
cur_price = sig['price']
|
||||||
|
vol_ratio = sig['vol_ratio']
|
||||||
|
|
||||||
|
# 이미 보유/매수대기 중인 종목 중복 방지
|
||||||
|
if ticker in positions or ticker in pending_buys:
|
||||||
|
return
|
||||||
|
|
||||||
|
# LLM 호출 전 포지션 수 재확인 (동시 진행 방지)
|
||||||
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
|
log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x → LLM 판단 요청")
|
||||||
|
|
||||||
|
llm_result = get_entry_price(
|
||||||
|
ticker=ticker,
|
||||||
|
signal=sig,
|
||||||
|
bar_list=bar_list,
|
||||||
|
current_price=cur_price,
|
||||||
|
num_positions=len(positions),
|
||||||
|
max_positions=MAX_POS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if llm_result is None or llm_result.get('action') != 'buy':
|
||||||
|
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
|
||||||
|
status = llm_result.get('market_status', '') if llm_result else ''
|
||||||
|
log.info(f"[매수/LLM] {ticker} → 스킵 | {reason}")
|
||||||
|
tg(
|
||||||
|
f"⏭️ <b>매수 스킵</b> {ticker}\n"
|
||||||
|
f"현재가: {fp(cur_price)}원 볼륨: {vol_ratio:.1f}x\n"
|
||||||
|
f"시장: {status}\n"
|
||||||
|
f"사유: {reason}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# LLM 호출 후 포지션 수/중복 재확인
|
||||||
|
if ticker in positions or ticker in pending_buys:
|
||||||
|
return
|
||||||
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
|
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
buy_price = _round_price(cur_price) # 현재가로 즉시 매수
|
||||||
|
confidence = llm_result.get('confidence', '?')
|
||||||
|
reason = llm_result.get('reason', '')
|
||||||
|
status = llm_result.get('market_status', '')
|
||||||
|
|
||||||
|
# 예산 체크: MAX_BUDGET - 현재 투자금 합계
|
||||||
|
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
|
||||||
|
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
|
||||||
|
remaining = MAX_BUDGET - invested
|
||||||
|
invest_amt = min(PER_POS, remaining)
|
||||||
|
if invest_amt < 5000:
|
||||||
|
log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
qty = invest_amt * (1 - FEE) / buy_price
|
||||||
|
log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)")
|
||||||
|
|
||||||
|
if SIM_MODE:
|
||||||
|
uuid = f"sim-buy-{ticker}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
order = upbit_client.buy_limit_order(ticker, buy_price, qty)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"지정가 매수 제출 실패: {order}")
|
||||||
|
return
|
||||||
|
uuid = order.get('uuid')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"지정가 매수 오류 {ticker}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_buys[ticker] = {
|
||||||
|
'uuid': uuid,
|
||||||
|
'price': buy_price,
|
||||||
|
'qty': qty,
|
||||||
|
'ts': datetime.now(),
|
||||||
|
'vol_ratio': vol_ratio,
|
||||||
|
}
|
||||||
|
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
|
||||||
|
order_uuid=uuid, invested_krw=int(qty * buy_price))
|
||||||
|
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
|
||||||
|
invested = int(qty * buy_price)
|
||||||
|
tg(
|
||||||
|
f"📥 <b>지정가 매수</b> {ticker}\n"
|
||||||
|
f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
|
||||||
|
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
|
||||||
|
f"확신: {confidence} 시장: {status}\n"
|
||||||
|
f"LLM: {reason}\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_pending_buys() -> None:
|
||||||
|
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소."""
|
||||||
|
for ticker in list(pending_buys.keys()):
|
||||||
|
pb = pending_buys[ticker]
|
||||||
|
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
||||||
|
|
||||||
|
# 포지션 한도 초과 시 미체결 주문 즉시 취소
|
||||||
|
if len(positions) >= MAX_POS:
|
||||||
|
cancel_order_safe(pb['uuid'])
|
||||||
|
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소")
|
||||||
|
sync_position(ticker, 'IDLE')
|
||||||
|
del pending_buys[ticker]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if SIM_MODE:
|
||||||
|
bar_list = list(bars.get(ticker, []))
|
||||||
|
if bar_list and bar_list[-1]['low'] <= pb['price']:
|
||||||
|
log.info(f"[SIM 매수체결] {ticker} {fp(pb['price'])}원")
|
||||||
|
_activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio'])
|
||||||
|
del pending_buys[ticker]
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
state, avg_price = check_order_state(pb['uuid'])
|
||||||
|
if state == 'done':
|
||||||
|
actual_price = avg_price or pb['price']
|
||||||
|
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
|
||||||
|
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
|
||||||
|
del pending_buys[ticker]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 타임아웃
|
||||||
|
if elapsed >= BUY_TIMEOUT:
|
||||||
|
cancel_order_safe(pb['uuid'])
|
||||||
|
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소")
|
||||||
|
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}원 {elapsed:.0f}초 미체결")
|
||||||
|
sync_position(ticker, 'IDLE')
|
||||||
|
del pending_buys[ticker]
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: float) -> None:
|
||||||
|
"""매수 체결 후 포지션 등록 (트레일링 스탑)."""
|
||||||
|
positions[ticker] = {
|
||||||
|
'entry_price': entry_price,
|
||||||
|
'entry_ts': datetime.now(),
|
||||||
|
'running_peak': entry_price,
|
||||||
|
'qty': qty,
|
||||||
|
}
|
||||||
|
invested = int(qty * entry_price)
|
||||||
|
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
|
||||||
|
qty=qty, invested_krw=invested)
|
||||||
|
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 트레일 -{TRAIL_PCT*100:.1f}%")
|
||||||
|
tg(
|
||||||
|
f"🟢 <b>매수 체결</b> {ticker}\n"
|
||||||
|
f"체결가: {fp(entry_price)}원 투자: {invested:,}원\n"
|
||||||
|
f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
||||||
|
positions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
||||||
|
"""체결 완료 후 포지션 종료 처리."""
|
||||||
|
pos = positions[ticker]
|
||||||
|
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
|
||||||
|
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
|
||||||
|
held = int((datetime.now() - pos['entry_ts']).total_seconds())
|
||||||
|
|
||||||
|
reason_tag = {
|
||||||
|
'trail': '트레일스탑', 'timeout': '타임아웃',
|
||||||
|
'stoploss': '손절', 'llm': 'LLM 매도',
|
||||||
|
}.get(tag, tag)
|
||||||
|
|
||||||
|
icon = "✅" if pnl > 0 else "🔴"
|
||||||
|
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
||||||
|
invested = int(pos['qty'] * pos['entry_price'])
|
||||||
|
tg(
|
||||||
|
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
||||||
|
f"투자: {invested:,}원\n"
|
||||||
|
f"진입: {fp(pos['entry_price'])}원 → 청산: {fp(exit_price)}원\n"
|
||||||
|
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
sync_position(ticker, 'IDLE')
|
||||||
|
del positions[ticker]
|
||||||
|
|
||||||
|
|
||||||
|
def check_filled_positions() -> None:
|
||||||
|
"""20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃."""
|
||||||
|
for ticker in list(positions.keys()):
|
||||||
|
if ticker not in positions:
|
||||||
|
continue
|
||||||
|
pos = positions[ticker]
|
||||||
|
bar_list = list(bars.get(ticker, []))
|
||||||
|
if not bar_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_price = bar_list[-1]['close']
|
||||||
|
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||||
|
|
||||||
|
# peak 갱신
|
||||||
|
pos['running_peak'] = max(pos['running_peak'], current_price)
|
||||||
|
|
||||||
|
profit_pct = (current_price - pos['entry_price']) / pos['entry_price']
|
||||||
|
drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
|
||||||
|
|
||||||
|
# 1. 손절: -2%
|
||||||
|
if profit_pct <= -STOP_LOSS_PCT:
|
||||||
|
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
||||||
|
log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)")
|
||||||
|
_record_exit(ticker, exit_price, 'stoploss')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5%
|
||||||
|
if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT:
|
||||||
|
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
||||||
|
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
|
||||||
|
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%")
|
||||||
|
_record_exit(ticker, exit_price, 'trail')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. 타임아웃: 4시간
|
||||||
|
if elapsed >= TIMEOUT_SECS:
|
||||||
|
exit_price = do_sell_market(ticker, pos['qty']) or current_price
|
||||||
|
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
|
||||||
|
_record_exit(ticker, exit_price, 'timeout')
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def update_positions(current_prices: dict) -> None:
|
||||||
|
"""tick마다 peak 갱신 (실시간 트레일링)."""
|
||||||
|
for ticker in list(positions.keys()):
|
||||||
|
if ticker not in current_prices:
|
||||||
|
continue
|
||||||
|
pos = positions[ticker]
|
||||||
|
price = current_prices[ticker]
|
||||||
|
|
||||||
|
pos['running_peak'] = max(pos['running_peak'], price)
|
||||||
|
|
||||||
|
# 실시간 손절 체크
|
||||||
|
profit_pct = (price - pos['entry_price']) / pos['entry_price']
|
||||||
|
if profit_pct <= -STOP_LOSS_PCT:
|
||||||
|
exit_price = do_sell_market(ticker, pos['qty']) or price
|
||||||
|
log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)")
|
||||||
|
_record_exit(ticker, exit_price, 'stoploss')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 실시간 트레일링 체크
|
||||||
|
drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
|
||||||
|
if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
|
||||||
|
exit_price = do_sell_market(ticker, pos['qty']) or price
|
||||||
|
log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}원")
|
||||||
|
_record_exit(ticker, exit_price, 'trail')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
||||||
|
def preload_bars() -> None:
|
||||||
|
need_min = (VOL_LOOKBACK + 10) // 3 + 1
|
||||||
|
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
|
||||||
|
loaded = 0
|
||||||
|
for ticker in TICKERS:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min)
|
||||||
|
if df is None or df.empty:
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
with bar_lock:
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close'])
|
||||||
|
v3 = float(row['volume']) / 3
|
||||||
|
ts = row.name.to_pydatetime()
|
||||||
|
for _ in range(3):
|
||||||
|
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
|
||||||
|
loaded += 1
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
|
||||||
|
time.sleep(1)
|
||||||
|
time.sleep(0.2)
|
||||||
|
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_positions() -> None:
|
||||||
|
"""Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응)."""
|
||||||
|
if SIM_MODE:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
balances = upbit_client.get_balances()
|
||||||
|
log.info(f"[복구] 잔고 조회: {len(balances)}건")
|
||||||
|
for b in balances:
|
||||||
|
currency = b.get('currency', '')
|
||||||
|
bal = float(b.get('balance', 0))
|
||||||
|
locked = float(b.get('locked', 0))
|
||||||
|
avg = float(b.get('avg_buy_price', 0))
|
||||||
|
total = bal + locked
|
||||||
|
if currency == 'KRW' or total <= 0 or avg <= 0:
|
||||||
|
continue
|
||||||
|
ticker = f'KRW-{currency}'
|
||||||
|
if ticker not in TICKERS:
|
||||||
|
log.info(f"[복구] {ticker} TICKERS 외 → 스킵")
|
||||||
|
continue
|
||||||
|
if ticker in positions:
|
||||||
|
continue
|
||||||
|
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}원")
|
||||||
|
# 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리)
|
||||||
|
try:
|
||||||
|
old_orders = upbit_client.get_order(ticker, state='wait') or []
|
||||||
|
for o in (old_orders if isinstance(old_orders, list) else []):
|
||||||
|
if o.get('side') == 'ask':
|
||||||
|
cancel_order_safe(o.get('uuid'))
|
||||||
|
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
|
||||||
|
# 취소 후 실제 가용 수량 재조회
|
||||||
|
time.sleep(0.5)
|
||||||
|
actual_bal = upbit_client.get_balance(currency)
|
||||||
|
if not actual_bal or actual_bal <= 0:
|
||||||
|
actual_bal = total
|
||||||
|
log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용")
|
||||||
|
positions[ticker] = {
|
||||||
|
'entry_price': avg,
|
||||||
|
'entry_ts': datetime.now(),
|
||||||
|
'running_peak': avg,
|
||||||
|
'qty': actual_bal,
|
||||||
|
}
|
||||||
|
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링")
|
||||||
|
tg(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}")
|
||||||
|
|
||||||
|
# 미체결 매수 주문 복구 → pending_buys
|
||||||
|
for ticker in TICKERS:
|
||||||
|
if ticker in positions or ticker in pending_buys:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
orders = upbit_client.get_order(ticker, state='wait') or []
|
||||||
|
for o in (orders if isinstance(orders, list) else []):
|
||||||
|
if o.get('side') == 'bid':
|
||||||
|
price = float(o.get('price', 0))
|
||||||
|
rem = float(o.get('remaining_volume', 0))
|
||||||
|
if price > 0 and rem > 0:
|
||||||
|
pending_buys[ticker] = {
|
||||||
|
'uuid': o.get('uuid'),
|
||||||
|
'price': price,
|
||||||
|
'qty': rem,
|
||||||
|
'ts': datetime.now(),
|
||||||
|
'vol_ratio': 0,
|
||||||
|
}
|
||||||
|
log.info(f"[복구] {ticker} 미체결 매수 복구: {fp(price)}원 수량:{rem:.6f}")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
restored = len(positions) + len(pending_buys)
|
||||||
|
if restored:
|
||||||
|
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
|
||||||
|
# 복구 결과를 position_sync에 반영
|
||||||
|
for ticker, pos in positions.items():
|
||||||
|
sync_position(ticker, 'PENDING_SELL', buy_price=pos['entry_price'],
|
||||||
|
qty=pos['qty'],
|
||||||
|
invested_krw=int(pos['qty'] * pos['entry_price']))
|
||||||
|
for ticker, pb in pending_buys.items():
|
||||||
|
sync_position(ticker, 'PENDING_BUY', buy_price=pb['price'],
|
||||||
|
qty=pb['qty'], order_uuid=pb.get('uuid'),
|
||||||
|
invested_krw=int(pb['qty'] * pb['price']))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
||||||
|
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
||||||
|
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
|
||||||
|
log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
|
||||||
|
tg(
|
||||||
|
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
|
||||||
|
f"예산: {MAX_BUDGET:,}원 | 최대 {MAX_POS}포지션 | 종목당 {PER_POS:,}원\n"
|
||||||
|
f"VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M | 연속양봉 >= 2\n"
|
||||||
|
f"트레일: 고점 -{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%)\n"
|
||||||
|
f"손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_SECS//3600}h"
|
||||||
|
)
|
||||||
|
|
||||||
|
preload_bars()
|
||||||
|
restore_positions()
|
||||||
|
|
||||||
|
t = threading.Thread(target=finalize_bars, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
ws = pyupbit.WebSocketManager("trade", TICKERS)
|
||||||
|
log.info("WebSocket 연결됨")
|
||||||
|
|
||||||
|
last_pos_log = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = ws.get()
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ticker = data.get('code')
|
||||||
|
price = data.get('trade_price')
|
||||||
|
volume = data.get('trade_volume')
|
||||||
|
|
||||||
|
if not ticker or price is None or volume is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
on_tick(ticker, float(price), float(volume))
|
||||||
|
|
||||||
|
if positions:
|
||||||
|
update_positions({ticker: float(price)})
|
||||||
|
|
||||||
|
if time.time() - last_pos_log > 60:
|
||||||
|
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
|
||||||
|
if positions:
|
||||||
|
pos_lines = ' '.join(
|
||||||
|
f"{t.split('-')[1]} {p['entry_price']:,.0f}→{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
|
||||||
|
for t, p in positions.items()
|
||||||
|
)
|
||||||
|
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
|
||||||
|
else:
|
||||||
|
log.info(f"[상태] 포지션 없음 ({warmed}/{len(TICKERS)} 준비완료)")
|
||||||
|
last_pos_log = time.time()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"루프 오류: {e}")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -11,5 +11,38 @@ module.exports = {
|
|||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "tick-collector",
|
||||||
|
script: "daemons/tick_collector.py",
|
||||||
|
interpreter: ".venv/bin/python3",
|
||||||
|
cwd: "/Users/joungmin/workspaces/upbit-trader",
|
||||||
|
out_file: "logs/tick-collector.log",
|
||||||
|
error_file: "logs/tick-collector-error.log",
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tick-trader",
|
||||||
|
script: "daemons/tick_trader.py",
|
||||||
|
interpreter: ".venv/bin/python3",
|
||||||
|
cwd: "/Users/joungmin/workspaces/upbit-trader",
|
||||||
|
out_file: "logs/tick-trader.log",
|
||||||
|
error_file: "logs/tick-trader-error.log",
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context-collector",
|
||||||
|
script: "daemons/context_collector.py",
|
||||||
|
interpreter: ".venv/bin/python3",
|
||||||
|
cwd: "/Users/joungmin/workspaces/upbit-trader",
|
||||||
|
out_file: "logs/context-collector.log",
|
||||||
|
error_file: "logs/context-collector-error.log",
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user