Compare commits
8 Commits
ab5c963803
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0c4508fa | ||
|
|
976c53ed66 | ||
|
|
872163a3d1 | ||
|
|
9944b55f94 | ||
|
|
526003c979 | ||
|
|
4f9e2c44c7 | ||
|
|
19a35e1009 | ||
|
|
7f1921441b |
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# upbit-trader
|
||||
|
||||
Upbit WebSocket 기반 20초봉 자동매매 봇. LLM(Gemini 2.5 Flash) 매수 판단 + 트레일링 스탑 청산.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
upbit-trader/
|
||||
STRATEGY.md -- 전략 상세 문서
|
||||
ecosystem.config.js -- PM2 프로세스 설정
|
||||
backtest_march.py -- 3월 백테스트 시뮬레이션
|
||||
|
||||
core/ -- Model / Service 레이어
|
||||
signal.py -- 시그널 감지 (양봉 + VOL + 사전필터 3종)
|
||||
order.py -- Upbit 주문 실행 (매수/매도/취소/조회)
|
||||
position_manager.py -- 포지션 관리, 청산 조건, DB sync, 복구
|
||||
llm_advisor.py -- LLM 매수 어드바이저 (OpenRouter + tool calling)
|
||||
notify.py -- 텔레그램 알림
|
||||
|
||||
daemons/ -- Controller / 데몬
|
||||
tick_trader.py -- 주력 트레이더 (WebSocket -> 봉 집계 -> 매매)
|
||||
tick_collector.py -- price_tick + 1분봉 Oracle 수집
|
||||
context_collector.py -- 종목 컨텍스트 수집 (뉴스 + 가격 통계)
|
||||
state_sync.py -- 포지션 상태 동기화
|
||||
|
||||
archive/ -- 미사용 파일 보관
|
||||
```
|
||||
|
||||
## 매매 전략 요약
|
||||
|
||||
### 진입
|
||||
|
||||
1. 20초봉 확정 시 시그널 감지 (양봉 + 거래량 5x + 거래대금 5M+)
|
||||
2. 사전 필터: 횡보(15봉 변동 < 0.3%), 고점(30분 구간 90%+), 연속양봉(2봉+)
|
||||
3. LLM(Gemini 2.5 Flash) 매수 판단 -> 현재가 지정가 매수
|
||||
|
||||
### 청산
|
||||
|
||||
| 조건 | 값 |
|
||||
|------|------|
|
||||
| 트레일링 스탑 | 고점 대비 -1.5% (최소 수익 +0.5%) |
|
||||
| 손절 | -2% |
|
||||
| 타임아웃 | 4시간 |
|
||||
|
||||
### 운용 설정
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|------|
|
||||
| 감시 종목 | 10개 (ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR) |
|
||||
| 총 예산 | 1,000,000원 |
|
||||
| 최대 포지션 | 5개 |
|
||||
| 종목당 투자 | 200,000원 |
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 구성 | 기술 |
|
||||
|------|------|
|
||||
| 거래소 | Upbit API (REST + WebSocket) |
|
||||
| DB | Oracle ADB |
|
||||
| LLM | Gemini 2.5 Flash via OpenRouter |
|
||||
| 알림 | Telegram Bot API |
|
||||
| 뉴스 | SearXNG (self-hosted) |
|
||||
| 프로세스 | PM2 |
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 환경 설정
|
||||
cp .env.example .env
|
||||
# .env에 API 키 입력
|
||||
|
||||
# PM2로 실행
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# 개별 실행
|
||||
.venv/bin/python3 daemons/tick_trader.py
|
||||
```
|
||||
|
||||
## PM2 데몬
|
||||
|
||||
| 이름 | 파일 | 설명 |
|
||||
|------|------|------|
|
||||
| tick-trader | `daemons/tick_trader.py` | 주력 트레이더 |
|
||||
| tick-collector | `daemons/tick_collector.py` | 가격 데이터 수집 |
|
||||
| context-collector | `daemons/context_collector.py` | 종목 컨텍스트 수집 |
|
||||
| state-sync | `daemons/state_sync.py` | 포지션 상태 동기화 |
|
||||
|
||||
## 백테스트 결과 (2026-03-01~06)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|------|
|
||||
| 거래 수 | 48건 |
|
||||
| 승률 | 52.1% |
|
||||
| 총 PNL | +17,868원 (+17.9%) |
|
||||
| 평균 PNL | +1.22% |
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `.env` 변경 후 PM2 재시작 시 `pm2 restart --update-env` 필수
|
||||
- 로그: `/tmp/tick_trader.log`
|
||||
437
STRATEGY.md
437
STRATEGY.md
@@ -1,309 +1,236 @@
|
||||
# Volume Lead 전략 가이드
|
||||
# upbit-trader 전략 가이드
|
||||
|
||||
## 전략 개요
|
||||
## 시스템 개요
|
||||
|
||||
**거래량 선행(Volume Lead) 매집 전략** — 가격이 횡보하는 중 거래량 급증이 발생하면
|
||||
매집 신호로 기록하고, 이후 일정 수준 이상 상승 시 진입하는 선진입 전략.
|
||||
|
||||
> 핵심 아이디어: 대형 매수자는 가격을 올리지 않고 조용히 매집한다.
|
||||
> 거래량이 먼저 급증하고, 가격 상승은 그 뒤에 따라온다.
|
||||
|
||||
**캔들 단위: 10분봉** (Upbit `minute10` API 직접 사용 — 리샘플링 없음)
|
||||
| 데몬 | 전략 | 상태 |
|
||||
|------|------|------|
|
||||
| `tick-trader` | WebSocket 20초봉 + LLM 매수 + 트레일링 청산 | **운용 중** |
|
||||
| `upbit-trader` | 10분봉 Volume Lead 매집 전략 | 중지 (2026-03-06~) |
|
||||
|
||||
---
|
||||
|
||||
## 진입 조건 (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)
|
||||
-> 지정가 매수 (현재가)
|
||||
-> 트레일링 스탑 / 손절 / 타임아웃 청산
|
||||
```
|
||||
|
||||
| 조건 | 파라미터 | 기본값 |
|
||||
|------|----------|--------|
|
||||
| QN봉(120분) 이전 종가 대비 가격 변동 < N% (횡보) | `PRICE_QUIET_PCT` | 2.0% |
|
||||
| 직전 완성 10분봉 거래량 ≥ 로컬 LV봉(280분=28봉) 평균 × M배 | `VOL_THRESH_*` | 5.0x / 6.0x |
|
||||
### 1.2 감시 종목 (10개)
|
||||
|
||||
- 신호 발생 시 텔레그램 🔍 알림 발송
|
||||
- `SIGNAL_TIMEOUT_MIN`(480분=8h) 초과 시 신호 초기화
|
||||
- **신호불사**: 가격이 신호가 아래로 내려가도 신호 유지 (sig_p 고정, 만료까지 대기)
|
||||
- **vol 갱신**: 더 강한 vol_ratio가 오면 신호가(sig_p)와 만료 시간 갱신
|
||||
```
|
||||
ETH, XRP, SOL, DOGE, SIGN, BARD, KITE, CFG, SXP, ARDR
|
||||
```
|
||||
|
||||
> **거래량 기준봉**: `df10["volume"].iloc[-2]` (직전 완성봉) vs 이전 28봉 평균
|
||||
> **횡보 기준봉**: `df10["close"].iloc[-(QUIET_CANDLES+1)]` = 12봉(120분) 이전 종가
|
||||
### 1.3 진입 조건
|
||||
|
||||
### 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 |
|
||||
|
||||
| 파라미터 | 기본값 | 설명 |
|
||||
|----------|--------|------|
|
||||
| `TREND_AFTER_VOL` | 4.8% | 신호가 대비 추가 상승 진입 임계값 |
|
||||
**LLM 매수 판단** -- 사전 필터 통과 후 LLM에게 매수 여부 위임:
|
||||
|
||||
### 신호 감시 스레드 (Fast Poll)
|
||||
- LLM이 DB Tool로 시장 데이터 조회 후 `buy` / `skip` 판단
|
||||
- `buy` 시 현재가로 지정가 매수 (LLM은 가격 결정 안 함)
|
||||
- `skip` 시 텔레그램 알림 + 사유 기록
|
||||
- 과거 연패/승률은 고려하지 않도록 프롬프팅 (get_trade_history 제거)
|
||||
|
||||
신호 감지 후 전체 스캔 60초를 기다리지 않고 해당 종목만 빠르게 폴링.
|
||||
**중복/한도 방지**:
|
||||
- 이미 보유(`positions`) 또는 매수대기(`pending_buys`) 종목은 스킵
|
||||
- LLM 호출 전/후 포지션 한도(`MAX_POS`) 이중 체크
|
||||
- 예산 체크: MAX_BUDGET - (보유 투자금 + 미체결 투자금)
|
||||
- 미체결 180초 초과 시 자동 취소
|
||||
|
||||
| 파라미터 | 기본값 | 설명 |
|
||||
|----------|--------|------|
|
||||
| `SCAN_INTERVAL` | 60초 | 전체 시장 스캔 주기 |
|
||||
| `SIGNAL_POLL_INTERVAL` | 15초 | 신호 종목 집중 감시 주기 |
|
||||
### 1.4 청산: 트레일링 스탑
|
||||
|
||||
LLM 매도는 제거됨. 규칙 기반 트레일링 스탑으로 청산.
|
||||
|
||||
| 조건 | 파라미터 | 값 | 설명 |
|
||||
|------|----------|------|------|
|
||||
| 트레일링 스탑 | `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 구간 | 레이블 | 거래량 임계값 | 진입 여부 |
|
||||
|----------|--------|-------------|-----------|
|
||||
| 0 ~ 40 | 극공포 / 공포 | **6.0x** (`VOL_THRESH_FEAR`) | ✅ 허용 |
|
||||
| 41 ~ 50 | 약공포 / 중립 | **5.0x** (`VOL_THRESH_NORMAL`) | ✅ 허용 |
|
||||
| 51 ~ 100 | 탐욕 / 극탐욕 | — | ❌ **전면 차단** |
|
||||
|
||||
- `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. 진입 후 추가 상승 여력 부족
|
||||
- 10분봉 Volume Lead 매집 전략
|
||||
- 횡보 중 거래량 급증 -> 신호 기록 -> +4.8% 상승 확인 후 진입
|
||||
- F&G 필터: <=40->6x / 41~50->5x / >50->차단
|
||||
- ATR 트레일링 스탑 + 타임스탑(8h)
|
||||
- WF 필터: Oracle DB 영속 저장
|
||||
|
||||
---
|
||||
|
||||
## 관찰 알림 (Watch Alert)
|
||||
## 3. 공통 인프라
|
||||
|
||||
신호 임계값(6x/5x)에 못 미치지만 근접한 종목을 텔레그램 👀 알림으로 통보.
|
||||
실제 매수 로직에는 영향 없음.
|
||||
### 3.1 데이터 수집 데몬
|
||||
|
||||
| 파라미터 | 기본값 | 설명 |
|
||||
|----------|--------|------|
|
||||
| `WATCH_VOL_THRESH` | 4.0x | 관찰 시작 임계값 |
|
||||
| `WATCH_COOLDOWN_MIN` | 30분 | 같은 종목 재알림 최소 간격 |
|
||||
| `WATCH_VOL_JUMP` | 0.5x | 쿨다운 무시 vol 상승폭 |
|
||||
| 데몬 | 주기 | 역할 |
|
||||
|------|------|------|
|
||||
| `tick-collector` | 30초 | `price_tick` 30초봉 + `backtest_ohlcv` 1분봉 Oracle 저장 |
|
||||
| `context-collector` | 1시간 | 종목별 `ticker_context` (가격 통계 + SearXNG 뉴스) |
|
||||
|
||||
- 조건: `WATCH_VOL_THRESH ≤ vol_r < vth` AND `chg < PRICE_QUIET_PCT`
|
||||
- 30분 쿨다운 (vol_r이 0.5x 이상 뛰면 즉시 재알림)
|
||||
### 3.2 기술 스택
|
||||
|
||||
| 구성 | 기술 |
|
||||
|------|------|
|
||||
| 거래소 | 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%` 범위 내 자동 조정
|
||||
- 최고가(peak) 대비 `stop_pct` 이하 하락 시 즉시 청산
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `daemons/tick_trader.py` | WebSocket 20초봉 트레이더 (LLM 매수 + 트레일링 청산) |
|
||||
| `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 데이터) |
|
||||
|
||||
---
|
||||
|
||||
## 리스크 관리
|
||||
|
||||
### 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)
|
||||
## 5. 운용 설정 (.env)
|
||||
|
||||
```env
|
||||
# 핵심 전략 (10분봉 직접 감지)
|
||||
LOCAL_VOL_CANDLES=28 # 로컬 vol 평균 구간 (28봉=280분)
|
||||
QUIET_CANDLES=12 # 횡보 체크 구간 (12봉=120분)
|
||||
PRICE_QUIET_PCT=2.0 # 횡보 기준 (%)
|
||||
TREND_AFTER_VOL=4.8 # 진입 임계값 (신호가 대비 %)
|
||||
SIGNAL_TIMEOUT_MIN=480 # 신호 유효 시간 (분=8h)
|
||||
# 총 운용 예산 / 최대 동시 보유 종목
|
||||
MAX_BUDGET=1000000
|
||||
MAX_POSITIONS=5
|
||||
|
||||
# F&G 기반 거래량 임계값
|
||||
VOL_THRESH_NORMAL=5.0 # 중립 구간 (F&G 41~50)
|
||||
VOL_THRESH_FEAR=6.0 # 공포/극공포 (F&G ≤ 40)
|
||||
FNG_FEAR_THRESHOLD=40 # 공포 기준 (이하 → FEAR 임계값)
|
||||
FNG_MAX_ENTRY=50 # 진입 허용 최대 F&G (초과 → 차단)
|
||||
# LLM (OpenRouter)
|
||||
OPENROUTER_API_KEY=sk-or-v1-...
|
||||
LLM_MODEL=google/gemini-2.5-flash
|
||||
|
||||
# 관찰 알림
|
||||
WATCH_VOL_THRESH=4.0 # 관찰 시작 임계값
|
||||
WATCH_COOLDOWN_MIN=30 # 재알림 최소 간격 (분)
|
||||
WATCH_VOL_JUMP=0.5 # 쿨다운 무시 vol 상승폭
|
||||
# Oracle ADB
|
||||
ORACLE_USER=admin
|
||||
ORACLE_PASSWORD=...
|
||||
ORACLE_DSN=...
|
||||
ORACLE_WALLET=...
|
||||
|
||||
# 청산
|
||||
TIME_STOP_MIN_PCT=3.0 # 타임스탑 최소 수익률
|
||||
# TS_N=48봉(480분), ATR_MULT=1.5, ATR_MIN=1%, ATR_MAX=2% (코드 내 고정)
|
||||
|
||||
# 포트폴리오
|
||||
MAX_BUDGET=15000000 # 초기 운용 예산
|
||||
MAX_POSITIONS=3 # 최대 동시 보유 종목
|
||||
|
||||
# 감시 주기
|
||||
SIGNAL_POLL_INTERVAL=15 # 신호 종목 빠른 감시 (초)
|
||||
|
||||
# WF 필터
|
||||
WF_WINDOW=5
|
||||
WF_MIN_WIN_RATE=0.40
|
||||
WF_SHADOW_WINS=2
|
||||
# Telegram
|
||||
TELEGRAM_TRADE_TOKEN=...
|
||||
TELEGRAM_CHAT_ID=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백테스트 결과 요약
|
||||
## 6. 백테스트 결과
|
||||
|
||||
### A. 1년 — 10분봉 직접, FNG_MAX_ENTRY=50 (`tests/sim_10m_vol.py`)
|
||||
> 기간: 2025-03-03 ~ 2026-03-03 / 데이터: Upbit minute10 캐시 / 20종목
|
||||
### 3월 시뮬레이션 (1분봉, 연속양봉 필터 적용)
|
||||
> 기간: 2026-03-01 ~ 2026-03-06 / 10종목 / MAX_POS=3
|
||||
|
||||
| 항목 | 필터 없음 | FNG_MAX_ENTRY=50 적용 |
|
||||
|------|---------|---------------------|
|
||||
| 수익률 | +6.80% | **+18.81%** |
|
||||
| 최대 낙폭 | -17.0% | **-14.3%** |
|
||||
| 거래수 | 286건 | 218건 |
|
||||
| 승률 | 42.0% | 46.3% |
|
||||
|
||||
### B. 월별 성과 — 1년 10분봉 (`tests/sim_regime_1y.py`)
|
||||
> 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건 |
|
||||
| 항목 | 필터 전 | 연속양봉 >= 2 적용 |
|
||||
|------|---------|-------------------|
|
||||
| 시그널 | 112건 | 48건 (-57%) |
|
||||
| 승률 | 38.4% | **52.1%** |
|
||||
| 총 PNL | +11,530원 | **+17,868원** |
|
||||
| 평균 PNL | +0.13% | **+1.22%** |
|
||||
| 손절 | 31건 | 12건 |
|
||||
| 트레일 익절 | 13건 | 18건 |
|
||||
|
||||
---
|
||||
|
||||
## 주요 파일
|
||||
|
||||
**프로덕션 코어**
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
## 7. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|------|---------|
|
||||
| 2026-03-04 | **전략 전면 재작성** — 10분봉 직접 감지, 신호불사, vol 갱신 |
|
||||
| 2026-03-04 | F&G 3구간 시스템: ≤40→6x / 41~50→5x / >50→차단 (`FNG_MAX_ENTRY=50`) |
|
||||
| 2026-03-04 | 관찰 알림(Watch Alert) 추가: 4x~신호임계값 근접 시 텔레그램 👀 알림 |
|
||||
| 2026-03-04 | 1년 10분봉 시뮬레이션 추가 (20종목, `data/sim1y_cache.pkl`) |
|
||||
| 2026-03-04 | THRESH 스윕 결과: 현재 4.8% 유지, 최적값 6.0% 확인 |
|
||||
| 2026-03-04 | runner.py: Bear레짐 차단 제거, FNG_MAX_ENTRY(>50) 차단으로 통일 |
|
||||
| 2026-03-03 | F&G 필터 최초 추가 (`FNG_MIN_ENTRY=41`) |
|
||||
| 2026-03-03 | 속도 기반 조기 진입 추가 (현재 제거됨) |
|
||||
| 2026-03-03 | 신호 종목 fast-poll 스레드 추가 (`SIGNAL_POLL_INTERVAL=15s`) |
|
||||
| 2026-03-03 | 프로젝트 구조 정리: `tests/` `data/` `logs/` 폴더 분리 |
|
||||
| 2026-03-06 | 연속 양봉 >= 2 필터 추가 (승률 38% -> 52%) |
|
||||
| 2026-03-06 | 예산 변경: 100K/3pos -> 1M/5pos (종목당 200K) |
|
||||
| 2026-03-06 | LLM 매도 제거 -> 트레일링 스탑 전환 (TRAIL -1.5%, SL -2%, 4h timeout) |
|
||||
| 2026-03-06 | cascade 매도 제거 |
|
||||
| 2026-03-06 | 사전 필터 3종 추가 (횡보/고점/연속양봉) -> LLM 호출 ~57% 절감 |
|
||||
| 2026-03-06 | 종목 30개 -> 10개 축소, BTC 제외 |
|
||||
| 2026-03-06 | 현재가 매수 (LLM 가격 제안 제거) |
|
||||
| 2026-03-06 | LLM 매수 프롬프트: 연패 무시, get_trade_history 제거 |
|
||||
| 2026-03-06 | VOL_KRW_MIN: 2M -> 5M, BUY_TIMEOUT: 60 -> 180초 |
|
||||
| 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중 체크 |
|
||||
|
||||
169
archive/daemons/fetch_1min_history.py
Normal file
169
archive/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
archive/daemons/live_trader.py
Normal file
363
archive/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()
|
||||
360
archive/tests/check_recent_signals.py
Normal file
360
archive/tests/check_recent_signals.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""최근 N일 볼륨 가속 시그널 확인 + ATR trail stop 시뮬레이션.
|
||||
|
||||
4봉 연속 가격+볼륨 가속 시그널 발생 후 실제 trail stop 로직을 돌려
|
||||
진입가·청산가·PNL을 표시.
|
||||
|
||||
최적 파라미터 (sweep_volaccel 기준):
|
||||
N봉=4, VOL≥5.0x, ATR_MULT=1.5, ATR_MIN=1.5%, ATR_MAX=2.0%
|
||||
"""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
# ── 파라미터 ──────────────────────────────────────────────────────────────────
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
LOOKBACK_DAYS = 3 # 오늘 기준 며칠 전까지 시그널 탐색
|
||||
VOL_MIN_LIST = [8.0]
|
||||
VOL_MIN = min(VOL_MIN_LIST)
|
||||
|
||||
# ATR 비교 목록: (MULT, MIN%, MAX%) — 타이트→여유 순
|
||||
ATR_SCENARIOS = [
|
||||
(1.0, 0.010, 0.020), # 타이트: 1.0×ATR, 1.0~2.0%
|
||||
(1.5, 0.015, 0.025), # 기존최적: 1.5×ATR, 1.5~2.5%
|
||||
(1.0, 0.020, 0.030), # 고정 2.0~3.0%
|
||||
(1.0, 0.030, 0.050), # 느슨: 3.0~5.0%
|
||||
]
|
||||
ATR_MULT = ATR_SCENARIOS[0][0]
|
||||
ATR_MIN_R = ATR_SCENARIOS[0][1]
|
||||
ATR_MAX_R = ATR_SCENARIOS[0][2]
|
||||
MAX_TRAIL_BARS = 240
|
||||
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
# ── 시그널 SQL (4봉 가속 조건 전부 Oracle SQL에서 처리) ───────────────────────
|
||||
SIGNAL_SQL = f"""
|
||||
WITH
|
||||
base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
|
||||
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
|
||||
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
|
||||
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
|
||||
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
|
||||
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
|
||||
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
|
||||
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
|
||||
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
|
||||
GREATEST(
|
||||
high_p - low_p,
|
||||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||||
) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd = 'minute1'
|
||||
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
indicators AS (
|
||||
SELECT ticker, ts, open_p, close_p,
|
||||
volume_p / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr0,
|
||||
prev_vol_1 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr1,
|
||||
prev_vol_2 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr2,
|
||||
prev_vol_3 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr3,
|
||||
prev_close_1, prev_open_1,
|
||||
prev_close_2, prev_open_2,
|
||||
prev_close_3, prev_open_3,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||||
/ NULLIF(prev_close_1, 0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts sig_ts, close_p sig_price,
|
||||
vr0, vr1, vr2, vr3, atr_raw,
|
||||
prev_close_3, prev_close_2, prev_close_1, close_p
|
||||
FROM indicators
|
||||
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= :min_vol
|
||||
-- 4봉 연속: 양봉
|
||||
AND close_p > open_p
|
||||
AND prev_close_1 > prev_open_1
|
||||
AND prev_close_2 > prev_open_2
|
||||
AND prev_close_3 > prev_open_3
|
||||
-- 4봉 연속: 가격 가속
|
||||
AND close_p > prev_close_1
|
||||
AND prev_close_1 > prev_close_2
|
||||
AND prev_close_2 > prev_close_3
|
||||
-- 4봉 연속: 볼륨 가속
|
||||
AND vr0 > vr1
|
||||
AND vr1 > vr2
|
||||
AND vr2 > vr3
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
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 simulate_trail(cur, ticker: str, entry_ts, entry_price: float,
|
||||
atr_raw: float,
|
||||
mult: float = None, min_r: float = None, max_r: float = None) -> dict:
|
||||
"""entry_ts 봉부터 trail stop 시뮬. 상세 결과 dict 반환."""
|
||||
m = mult if mult is not None else ATR_MULT
|
||||
n = min_r if min_r is not None else ATR_MIN_R
|
||||
x = max_r if max_r is not None else ATR_MAX_R
|
||||
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
|
||||
atr_stop = max(n, min(x, ar * m)) if ar > 0 else x
|
||||
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry
|
||||
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = cur.fetchall()
|
||||
if not bars:
|
||||
return dict(status="데이터없음", exit_ts=entry_ts, exit_price=entry_price,
|
||||
peak=entry_price, peak_pct=0.0, pnl=0.0, atr_stop=atr_stop,
|
||||
held_bars=0)
|
||||
|
||||
running_peak = entry_price
|
||||
for i, (ts, close_p) in enumerate(bars):
|
||||
close_p = float(close_p)
|
||||
running_peak = max(running_peak, close_p)
|
||||
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
|
||||
if drop >= atr_stop:
|
||||
pnl = (close_p - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
return dict(status="손절", exit_ts=ts, exit_price=close_p,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, atr_stop=atr_stop, held_bars=i + 1)
|
||||
|
||||
last_ts, last_price = bars[-1][0], float(bars[-1][1])
|
||||
pnl = (last_price - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
|
||||
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, atr_stop=atr_stop, held_bars=len(bars))
|
||||
|
||||
|
||||
def apply_pos_limit(sim_results: list, vol_thr: float) -> tuple:
|
||||
"""VOL 필터 + MAX_POS 동시 포지션 제한 적용. (taken, skipped) 반환."""
|
||||
filtered = [r for r in sim_results if r['vr'][3] >= vol_thr]
|
||||
open_positions = []
|
||||
taken, skipped = [], []
|
||||
for r in filtered:
|
||||
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
|
||||
if len(open_positions) < MAX_POS:
|
||||
open_positions.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def print_detail(vol_thr: float, taken: list, skipped: list):
|
||||
div = "─" * 120
|
||||
|
||||
print(f"\n{'━'*120}")
|
||||
print(f" 4봉 VOL≥{vol_thr:.0f}x | ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%] "
|
||||
f"| 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개")
|
||||
print(f"{'━'*120}\n")
|
||||
|
||||
total_krw, total_wins = 0.0, 0
|
||||
|
||||
for i, r in enumerate(taken, 1):
|
||||
vr = r['vr']
|
||||
entry_str = str(r['entry_ts'])[:16]
|
||||
exit_str = str(r['exit_ts'])[:16]
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
total_krw += krw
|
||||
won = r['pnl'] > 0
|
||||
if won:
|
||||
total_wins += 1
|
||||
|
||||
pr = r['prices']
|
||||
# 봉별 가격 변화율 (봉1→2, 2→3, 3→4)
|
||||
pchg = [
|
||||
(pr[1] - pr[0]) / pr[0] * 100 if pr[0] else 0,
|
||||
(pr[2] - pr[1]) / pr[1] * 100 if pr[1] else 0,
|
||||
(pr[3] - pr[2]) / pr[2] * 100 if pr[2] else 0,
|
||||
]
|
||||
|
||||
sign = "▲" if won else "▼"
|
||||
print(f" #{i:02d} {r['ticker']:12s} [{sign}]")
|
||||
print(f" 가격흐름 {pr[0]:,.0f} → {pr[1]:,.0f} ({pchg[0]:+.2f}%)"
|
||||
f" → {pr[2]:,.0f} ({pchg[1]:+.2f}%)"
|
||||
f" → {pr[3]:,.0f} ({pchg[2]:+.2f}%)")
|
||||
print(f" 볼륨흐름 {vr[0]:.1f}x → {vr[1]:.1f}x → {vr[2]:.1f}x → {vr[3]:.1f}x "
|
||||
f"(ATR손절 {r['atr_stop']*100:.1f}%)")
|
||||
print(f" 진입 {entry_str} @ {r['entry_price']:>13,.0f}원")
|
||||
print(f" 고점 {r['peak']:>13,.0f}원 ({r['peak_pct']:>+.2f}%까지 상승)")
|
||||
print(f" 청산 {exit_str} @ {r['exit_price']:>13,.0f}원"
|
||||
f" ({r['status']}, {r['held_bars']}봉 보유)")
|
||||
print(f" 손익 {r['pnl']:>+.2f}% → {krw:>+,.0f}원")
|
||||
print()
|
||||
|
||||
if skipped:
|
||||
print(f" ── 스킵 {len(skipped)}건 (포지션 한도 초과) ──")
|
||||
for r in skipped:
|
||||
vr = r['vr']
|
||||
krw_if = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
print(f" SKIP {r['ticker']:12s} 진입 {str(r['entry_ts'])[:16]} "
|
||||
f"vol {vr[3]:.1f}x → {r['pnl']:>+.2f}% ({krw_if:>+,.0f}원 기회손실)")
|
||||
print()
|
||||
|
||||
n = len(taken)
|
||||
win_rate = total_wins / n * 100 if n else 0
|
||||
ret_rate = total_krw / BUDGET * 100
|
||||
print(div)
|
||||
print(f" ◆ 실거래 {n}건 | 승률 {win_rate:.0f}% ({total_wins}/{n}) | "
|
||||
f"합산 {total_krw:>+,.0f}원 | 수익률 {ret_rate:>+.2f}%")
|
||||
print(div)
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
print(f"=== 4봉 볼륨 가속 시뮬 — VOL {VOL_MIN_LIST} 비교 ===")
|
||||
print(f"ATR={ATR_MULT}×[{ATR_MIN_R*100:.1f}~{ATR_MAX_R*100:.1f}%], "
|
||||
f"자본 {BUDGET/10000:.0f}만원 / 포지션당 {PER_POS/10000:.0f}만원 / 동시 {MAX_POS}개")
|
||||
print(f"조회 기간: {check_since[:10]} ~ 현재\n")
|
||||
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10_000
|
||||
|
||||
# ── 1회 조회 (VOL_MIN = 가장 낮은 값) ────────────────────────────────────────
|
||||
cur.execute(SIGNAL_SQL, {
|
||||
"warmup_since": warmup_since,
|
||||
"check_since": check_since,
|
||||
"min_vol": VOL_MIN,
|
||||
})
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 진입봉 조회
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
|
||||
p3, p2, p1, p0 = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{"t": ticker, "sig": sig_ts}
|
||||
)
|
||||
entry_row = cur.fetchone()
|
||||
if not entry_row:
|
||||
continue
|
||||
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
|
||||
signals.append({
|
||||
'ticker': ticker,
|
||||
'sig_ts': sig_ts,
|
||||
'entry_ts': entry_ts,
|
||||
'entry_price': entry_price,
|
||||
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
|
||||
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
})
|
||||
|
||||
if not signals:
|
||||
print("진입봉을 찾을 수 없음")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# ── trail stop 시뮬 — 시나리오별 ─────────────────────────────────────────────
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
vol_thr = VOL_MIN_LIST[0]
|
||||
|
||||
# 각 시나리오에 맞는 bars를 재사용하기 위해 기본(가장 느슨한) 시나리오로 bars 캐시
|
||||
# (atr_stop 달라도 bars 조회는 동일하므로 1회만 fetch)
|
||||
print(f"시그널 {len(signals)}건 trail stop 시뮬 중 ({len(ATR_SCENARIOS)}가지 ATR 비교)...", flush=True)
|
||||
|
||||
# 시나리오별 결과 수집
|
||||
scenario_results = []
|
||||
for mult, min_r, max_r in ATR_SCENARIOS:
|
||||
sim = []
|
||||
for s in signals:
|
||||
trail = simulate_trail(cur, s['ticker'], s['entry_ts'], s['entry_price'],
|
||||
s['atr_raw'], mult=mult, min_r=min_r, max_r=max_r)
|
||||
sim.append({**s, **trail})
|
||||
taken, skipped = apply_pos_limit(sim, vol_thr)
|
||||
scenario_results.append((mult, min_r, max_r, taken, skipped))
|
||||
|
||||
# ── 시나리오 요약 비교표 ──────────────────────────────────────────────────────
|
||||
print(f"\n{'━'*95}")
|
||||
print(f" {'ATR 설정':22s} {'거래':>4s} {'승률':>5s} {'합산손익':>12s} {'수익률':>6s} {'평균보유(분)':>10s}")
|
||||
print(f"{'━'*95}")
|
||||
best_krw = max(sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
|
||||
for _, _, _, taken, _ in scenario_results)
|
||||
for mult, min_r, max_r, taken, skipped in scenario_results:
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
continue
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
total = sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2 for r in taken)
|
||||
avg_held = sum(r['held_bars'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
label = f"{mult:.1f}×ATR [{min_r*100:.1f}~{max_r*100:.1f}%]"
|
||||
star = " ★" if abs(total - best_krw) < 1 else ""
|
||||
print(f" {label:22s} {n:>4d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 {ret:>+5.2f}%"
|
||||
f" {avg_held:>6.0f}봉{star}")
|
||||
print(f"{'━'*95}")
|
||||
|
||||
# ── 최적 시나리오 건별 상세 출력 ─────────────────────────────────────────────
|
||||
best_idx = max(range(len(scenario_results)),
|
||||
key=lambda i: sum(PER_POS*(r['pnl']/100) - PER_POS*FEE*2
|
||||
for r in scenario_results[i][3]))
|
||||
bm, bn, bx, best_taken, best_skipped = scenario_results[best_idx]
|
||||
print(f"\n[최적 시나리오 건별 상세: {bm:.1f}×ATR {bn*100:.1f}~{bx*100:.1f}%]")
|
||||
print_detail(vol_thr, best_taken, best_skipped)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
323
archive/tests/compare_tp_vs_trail.py
Normal file
323
archive/tests/compare_tp_vs_trail.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""3% 고정 익절 vs 현재 trail stop 전략 비교 시뮬레이션.
|
||||
|
||||
전략:
|
||||
A. Trail Stop only : ATR 1.0×[3.0~5.0%]
|
||||
B. TP 3% + Trail Stop 손절 : +3% 도달 시 익절, 못 도달하면 trail stop
|
||||
C. TP 2% + Trail Stop 손절 : +2% 도달 시 익절
|
||||
D. TP 5% + Trail Stop 손절 : +5% 도달 시 익절
|
||||
"""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
# ── 파라미터 ──────────────────────────────────────────────────────────────────
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
LOOKBACK_DAYS = 3
|
||||
VOL_MIN = 8.0
|
||||
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
SIGNAL_SQL = f"""
|
||||
WITH
|
||||
base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close_1,
|
||||
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open_1,
|
||||
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_1,
|
||||
LAG(close_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_close_2,
|
||||
LAG(open_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_open_2,
|
||||
LAG(volume_p, 2) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_2,
|
||||
LAG(close_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_close_3,
|
||||
LAG(open_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_open_3,
|
||||
LAG(volume_p, 3) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_3,
|
||||
GREATEST(
|
||||
high_p - low_p,
|
||||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||||
) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd = 'minute1'
|
||||
AND ts >= TO_TIMESTAMP(:warmup_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
indicators AS (
|
||||
SELECT ticker, ts, open_p, close_p,
|
||||
volume_p / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr0,
|
||||
prev_vol_1 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr1,
|
||||
prev_vol_2 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr2,
|
||||
prev_vol_3 / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vr3,
|
||||
prev_close_1, prev_open_1,
|
||||
prev_close_2, prev_open_2,
|
||||
prev_close_3, prev_open_3,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||||
/ NULLIF(prev_close_1, 0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts sig_ts, close_p sig_price,
|
||||
vr0, vr1, vr2, vr3, atr_raw,
|
||||
prev_close_3, prev_close_2, prev_close_1, close_p
|
||||
FROM indicators
|
||||
WHERE ts >= TO_TIMESTAMP(:check_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= :min_vol
|
||||
AND close_p > open_p
|
||||
AND prev_close_1 > prev_open_1
|
||||
AND prev_close_2 > prev_open_2
|
||||
AND prev_close_3 > prev_open_3
|
||||
AND close_p > prev_close_1
|
||||
AND prev_close_1 > prev_close_2
|
||||
AND prev_close_2 > prev_close_3
|
||||
AND vr0 > vr1 AND vr1 > vr2 AND vr2 > vr3
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
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 _fetch_bars(cur, ticker, entry_ts):
|
||||
"""진입 시점 이후 봉 데이터 조회 (캐시용)."""
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry
|
||||
ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{"t": ticker, "entry": entry_ts, "n": MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
return [(ts, float(cp), float(hp)) for ts, cp, hp in cur.fetchall()]
|
||||
|
||||
|
||||
def simulate_trail_only(bars, entry_price, atr_raw):
|
||||
"""전략 A: Trail Stop만 사용."""
|
||||
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
|
||||
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
|
||||
running_peak = entry_price
|
||||
for i, (ts, close_p, _) in enumerate(bars):
|
||||
running_peak = max(running_peak, close_p)
|
||||
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
|
||||
if drop >= atr_stop:
|
||||
pnl = (close_p - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, held_bars=i + 1)
|
||||
|
||||
last_ts, last_price = bars[-1][0], bars[-1][1]
|
||||
pnl = (last_price - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
|
||||
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, held_bars=len(bars))
|
||||
|
||||
|
||||
def simulate_tp_trail(bars, entry_price, atr_raw, tp_r):
|
||||
"""전략 B/C/D: 고정 익절 + Trail Stop 손절.
|
||||
|
||||
먼저 +tp_r% 도달하면 익절.
|
||||
못 도달하고 trail stop 걸리면 손절.
|
||||
"""
|
||||
ar = atr_raw if (atr_raw and atr_raw == atr_raw) else 0.0
|
||||
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp_price = entry_price * (1 + tp_r)
|
||||
|
||||
running_peak = entry_price
|
||||
for i, (ts, close_p, high_p) in enumerate(bars):
|
||||
# 고가로 익절 체크 (봉 내 고가가 익절가 도달하면 익절)
|
||||
if high_p >= tp_price:
|
||||
pnl = tp_r * 100
|
||||
peak_pct = (max(running_peak, high_p) - entry_price) / entry_price * 100
|
||||
return dict(status=f"익절+{tp_r*100:.0f}%", exit_ts=ts, exit_price=tp_price,
|
||||
peak=max(running_peak, high_p), peak_pct=peak_pct,
|
||||
pnl=pnl, held_bars=i + 1)
|
||||
|
||||
running_peak = max(running_peak, close_p)
|
||||
drop = (running_peak - close_p) / running_peak if running_peak > 0 else 0.0
|
||||
if drop >= atr_stop:
|
||||
pnl = (close_p - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
return dict(status="트레일손절", exit_ts=ts, exit_price=close_p,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, held_bars=i + 1)
|
||||
|
||||
last_ts, last_price = bars[-1][0], bars[-1][1]
|
||||
pnl = (last_price - entry_price) / entry_price * 100
|
||||
peak_pct = (running_peak - entry_price) / entry_price * 100
|
||||
status = "타임아웃" if len(bars) >= MAX_TRAIL_BARS else "진행중"
|
||||
return dict(status=status, exit_ts=last_ts, exit_price=last_price,
|
||||
peak=running_peak, peak_pct=peak_pct,
|
||||
pnl=pnl, held_bars=len(bars))
|
||||
|
||||
|
||||
def apply_pos_limit(sim_results):
|
||||
open_positions = []
|
||||
taken, skipped = [], []
|
||||
for r in sim_results:
|
||||
open_positions = [ex for ex in open_positions if ex > r['entry_ts']]
|
||||
if len(open_positions) < MAX_POS:
|
||||
open_positions.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def print_summary(label, taken):
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
print(f" {label:35s} 거래없음")
|
||||
return
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||||
avg_h = sum(r['held_bars'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
print(f" {label:35s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 {ret:>+5.2f}% {avg_h:>5.0f}봉")
|
||||
|
||||
|
||||
def print_detail(label, taken):
|
||||
print(f"\n{'━'*110}")
|
||||
print(f" {label}")
|
||||
print(f"{'━'*110}")
|
||||
for i, r in enumerate(taken, 1):
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
sign = "▲" if r['pnl'] > 0 else "▼"
|
||||
print(f" #{i:02d} {r['ticker']:12s} [{sign}] 진입 {str(r['entry_ts'])[:16]} "
|
||||
f"고점 {r['peak_pct']:>+.2f}% → {r['status']} {r['held_bars']}봉 "
|
||||
f"PNL {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
print(f"=== 고정 익절 vs Trail Stop 비교 시뮬 ===")
|
||||
print(f"기간: {check_since[:10]} ~ 현재 | 4봉 VOL≥{VOL_MIN}x | "
|
||||
f"손절: ATR {ATR_MULT}×[{ATR_MIN_R*100:.0f}~{ATR_MAX_R*100:.0f}%]")
|
||||
print(f"자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개\n")
|
||||
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10_000
|
||||
|
||||
cur.execute(SIGNAL_SQL, {
|
||||
"warmup_since": warmup_since,
|
||||
"check_since": check_since,
|
||||
"min_vol": VOL_MIN,
|
||||
})
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
print(f"해당 기간 VOL>={VOL_MIN}x 4봉 가속 시그널 없음")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 진입봉 + bars 수집
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, sig_price, vr0, vr1, vr2, vr3, atr_raw, \
|
||||
p3, p2, p1, p0 = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{"t": ticker, "sig": sig_ts}
|
||||
)
|
||||
entry_row = cur.fetchone()
|
||||
if not entry_row:
|
||||
continue
|
||||
entry_price, entry_ts = float(entry_row[0]), entry_row[1]
|
||||
bars = _fetch_bars(cur, ticker, entry_ts)
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker,
|
||||
'entry_ts': entry_ts,
|
||||
'entry_price': entry_price,
|
||||
'vr': [float(x) if x else 0.0 for x in [vr3, vr2, vr1, vr0]],
|
||||
'prices': [float(x) if x else 0.0 for x in [p3, p2, p1, p0]],
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
print(f"시그널 {len(signals)}건 → 전략별 시뮬 중...\n")
|
||||
|
||||
# ── 전략별 시뮬 ───────────────────────────────────────────────────────────
|
||||
strategies = [
|
||||
("A. Trail Stop [3~5%]", None),
|
||||
("B. 익절 2% + Trail Stop", 0.02),
|
||||
("C. 익절 3% + Trail Stop", 0.03),
|
||||
("D. 익절 5% + Trail Stop", 0.05),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for label, tp_r in strategies:
|
||||
sim = []
|
||||
for s in signals:
|
||||
if tp_r is None:
|
||||
r = simulate_trail_only(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
else:
|
||||
r = simulate_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
|
||||
sim.append({**s, **r})
|
||||
taken, _ = apply_pos_limit(sim)
|
||||
results[label] = taken
|
||||
|
||||
# ── 요약 비교표 ───────────────────────────────────────────────────────────
|
||||
print(f"{'━'*95}")
|
||||
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s}")
|
||||
print(f"{'━'*95}")
|
||||
for label, taken in results.items():
|
||||
print_summary(label, taken)
|
||||
print(f"{'━'*95}")
|
||||
|
||||
# ── 전략별 건별 상세 ──────────────────────────────────────────────────────
|
||||
for label, taken in results.items():
|
||||
print_detail(label, taken)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
131
archive/tests/fetch_1y_minute1.py
Normal file
131
archive/tests/fetch_1y_minute1.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""1년치 1분봉 OHLCV → BACKTEST_OHLCV (수동 페이지네이션).
|
||||
|
||||
pyupbit이 count>5000에서 실패하므로 to 파라미터로 직접 페이지네이션.
|
||||
실행: .venv/bin/python3 tests/fetch_1y_minute1.py
|
||||
예상 소요: 20종목 × ~15분 = ~5시간 (overnight 실행 권장)
|
||||
"""
|
||||
import sys, os, time
|
||||
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
|
||||
from datetime import datetime, timedelta
|
||||
import pandas as pd
|
||||
|
||||
ALL_TICKERS = [
|
||||
'KRW-XRP','KRW-BTC','KRW-ETH','KRW-SOL','KRW-DOGE', # 그룹 1
|
||||
'KRW-ADA','KRW-SUI','KRW-NEAR','KRW-KAVA','KRW-SXP', # 그룹 2
|
||||
'KRW-AKT','KRW-SONIC','KRW-IP','KRW-ORBS','KRW-VIRTUAL', # 그룹 3
|
||||
'KRW-BARD','KRW-XPL','KRW-KITE','KRW-ENSO','KRW-0G', # 그룹 4
|
||||
]
|
||||
# 실행: python fetch_1y_minute1.py [그룹번호 1-4]
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('group', nargs='?', type=int, default=0,
|
||||
help='1-4: 해당 그룹만 실행, 0: 전체')
|
||||
args, _ = parser.parse_known_args()
|
||||
if args.group in (1,2,3,4):
|
||||
TICKERS = ALL_TICKERS[(args.group-1)*5 : args.group*5]
|
||||
print(f"그룹 {args.group} 실행: {TICKERS}")
|
||||
else:
|
||||
TICKERS = ALL_TICKERS
|
||||
BATCH = 4000 # 한 번에 요청할 봉 수 (4000 = ~2.8일)
|
||||
DELAY = 0.15 # API 딜레이 (초)
|
||||
TARGET_DAYS = 365 # 목표 기간
|
||||
|
||||
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 insert_batch(conn, ticker, df) -> int:
|
||||
"""DB에 없는 행만 삽입. 반환: 신규 건수."""
|
||||
cur = conn.cursor()
|
||||
min_ts = df.index.min().to_pydatetime()
|
||||
max_ts = df.index.max().to_pydatetime()
|
||||
cur.execute(
|
||||
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd='minute1' "
|
||||
"AND ts BETWEEN :s AND :e",
|
||||
{"t": ticker, "s": min_ts, "e": max_ts}
|
||||
)
|
||||
existing = {r[0] for r in cur.fetchall()}
|
||||
new_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()
|
||||
if ts.to_pydatetime() not in existing
|
||||
]
|
||||
if not new_rows:
|
||||
return 0
|
||||
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)",
|
||||
new_rows
|
||||
)
|
||||
conn.commit()
|
||||
return len(new_rows)
|
||||
|
||||
|
||||
def fetch_ticker(conn, ticker) -> int:
|
||||
"""ticker 1년치 1분봉 fetch → DB 저장. 반환: 총 신규 건수."""
|
||||
cutoff = datetime.now() - timedelta(days=TARGET_DAYS)
|
||||
to_dt = datetime.now()
|
||||
total = 0
|
||||
batch_no = 0
|
||||
|
||||
while to_dt > cutoff:
|
||||
to_str = to_dt.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:
|
||||
print(f" API 오류: {e} → 재시도")
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
|
||||
if df is None or len(df) == 0:
|
||||
break
|
||||
|
||||
n = insert_batch(conn, ticker, df)
|
||||
total += n
|
||||
batch_no += 1
|
||||
oldest = df.index[0]
|
||||
|
||||
print(f" 배치{batch_no:03d}: {oldest.date()} ~ {df.index[-1].strftime('%m-%d')} "
|
||||
f"({len(df)}봉, 신규 {n}) | 누적 {total:,}", flush=True)
|
||||
|
||||
# 다음 페이지: 이 배치에서 가장 오래된 봉 이전부터
|
||||
to_dt = oldest - timedelta(minutes=1)
|
||||
|
||||
if oldest <= cutoff:
|
||||
break
|
||||
|
||||
return total
|
||||
|
||||
|
||||
conn = _get_conn()
|
||||
grand_total = 0
|
||||
start_time = time.time()
|
||||
|
||||
for idx, tk in enumerate(TICKERS, 1):
|
||||
t0 = time.time()
|
||||
print(f"\n[{idx:02d}/{len(TICKERS)}] {tk} 시작...", flush=True)
|
||||
try:
|
||||
n = fetch_ticker(conn, tk)
|
||||
elapsed = time.time() - t0
|
||||
grand_total += n
|
||||
print(f" → 완료: 신규 {n:,}행 ({elapsed/60:.1f}분) | 전체 누적 {grand_total:,}행", flush=True)
|
||||
except Exception as e:
|
||||
print(f" → 오류: {e}")
|
||||
|
||||
conn.close()
|
||||
elapsed_total = time.time() - start_time
|
||||
print(f"\n전체 완료: {grand_total:,}행 저장 ({elapsed_total/60:.0f}분 소요)")
|
||||
314
archive/tests/sim_3bar.py
Normal file
314
archive/tests/sim_3bar.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""3봉 vol가속 시그널 + 다양한 청산 전략 비교 시뮬 (30일)."""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
LOOKBACK_DAYS = 30
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
VOL_MIN = 8.0
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── 3봉 시그널 SQL ─────────────────────────────────────────────────────────────
|
||||
SIGNAL_SQL_3BAR = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
pc1,po1,pc2,po2,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
-- 3봉 연속 양봉
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2
|
||||
-- 3봉 연속 가격 가속
|
||||
AND close_p>pc1 AND pc1>pc2
|
||||
-- 3봉 연속 볼륨 가속
|
||||
AND vr0>vr1 AND vr1>vr2
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
# ── 4봉 시그널 SQL (비교용) ────────────────────────────────────────────────────
|
||||
SIGNAL_SQL_4BAR = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
|
||||
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
|
||||
pc1,po1,pc2,po2,pc3,po3,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
|
||||
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
|
||||
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
def fetch_signals(cur, sql, warmup_since, check_since):
|
||||
cur.execute(sql, {'ws': warmup_since, 'cs': check_since})
|
||||
rows = cur.fetchall()
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row[:6]
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{'t': ticker, 'sig': sig_ts}
|
||||
)
|
||||
er = cur.fetchone()
|
||||
if not er:
|
||||
continue
|
||||
ep, ets = float(er[0]), er[1]
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
return signals
|
||||
|
||||
|
||||
# ── 청산 전략 함수들 ───────────────────────────────────────────────────────────
|
||||
def sim_trail(bars, ep, ar):
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_tp_trail(bars, ep, ar, tp_r):
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp = ep * (1 + tp_r)
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if hp >= tp:
|
||||
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_tp_sl(bars, ep, tp_r, sl_r):
|
||||
tp = ep * (1 + tp_r)
|
||||
sl = ep * (1 - sl_r)
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if lp <= sl:
|
||||
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
|
||||
pnl=-sl_r * 100, held=i + 1)
|
||||
if hp >= tp:
|
||||
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def pos_limit(sim):
|
||||
opens, taken, skipped = [], [], []
|
||||
for r in sim:
|
||||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||||
if len(opens) < MAX_POS:
|
||||
opens.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def run_strategies(signals, strategies):
|
||||
results = {}
|
||||
for label, mode, tp_r, sl_r in strategies:
|
||||
sim = []
|
||||
for s in signals:
|
||||
if mode == 'trail':
|
||||
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
elif mode == 'tp_trail':
|
||||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
|
||||
else:
|
||||
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
|
||||
sim.append({**s, **r})
|
||||
taken, _ = pos_limit(sim)
|
||||
results[label] = taken
|
||||
return results
|
||||
|
||||
|
||||
def print_table(title, results, strategies):
|
||||
print(f"\n{'━'*105}")
|
||||
print(f" {title}")
|
||||
print(f"{'━'*105}")
|
||||
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
|
||||
print(f"{'─'*105}")
|
||||
for label, _, _, _ in strategies:
|
||||
taken = results.get(label, [])
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
print(f" {label:32s} 거래없음")
|
||||
continue
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
losses = sum(1 for r in taken if r['pnl'] < 0)
|
||||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||||
avg_h = sum(r['held'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
|
||||
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
|
||||
rr = avg_w / avg_l
|
||||
print(f" {label:32s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 "
|
||||
f"{ret:>+5.2f}% {avg_h:>5.0f}봉 {rr:>4.1f}:1")
|
||||
print(f"{'━'*105}")
|
||||
|
||||
|
||||
def print_detail(label, taken):
|
||||
print(f"\n[{label} 건별 상세]")
|
||||
print(f"{'─'*105}")
|
||||
for i, r in enumerate(taken, 1):
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
sign = '▲' if r['pnl'] > 0 else '▼'
|
||||
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
|
||||
f"→ {r['status']:14s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10000
|
||||
|
||||
print(f"=== 3봉 vs 4봉 진입 비교 시뮬 ===")
|
||||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)")
|
||||
print(f"VOL≥{VOL_MIN}x | 자본 {BUDGET//10000}만원 / 포지션 {PER_POS//10000}만원 / 동시 {MAX_POS}개\n")
|
||||
|
||||
# 시그널 수집
|
||||
print("3봉 시그널 수집 중...", flush=True)
|
||||
sigs_3 = fetch_signals(cur, SIGNAL_SQL_3BAR, warmup_since, check_since)
|
||||
print("4봉 시그널 수집 중...", flush=True)
|
||||
sigs_4 = fetch_signals(cur, SIGNAL_SQL_4BAR, warmup_since, check_since)
|
||||
print(f" → 3봉: {len(sigs_3)}건 / 4봉: {len(sigs_4)}건\n")
|
||||
|
||||
strategies = [
|
||||
('TP 3% + Trail Stop [3~5%]', 'tp_trail', 0.03, None ),
|
||||
('TP 2% + Trail Stop [3~5%]', 'tp_trail', 0.02, None ),
|
||||
('TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
|
||||
('TP 2% + SL 3%', 'tp_sl', 0.02, 0.03 ),
|
||||
('TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
|
||||
('TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
|
||||
]
|
||||
|
||||
res_3 = run_strategies(sigs_3, strategies)
|
||||
res_4 = run_strategies(sigs_4, strategies)
|
||||
|
||||
print_table(f"【3봉 진입】 시그널 {len(sigs_3)}건", res_3, strategies)
|
||||
print_table(f"【4봉 진입】 시그널 {len(sigs_4)}건", res_4, strategies)
|
||||
|
||||
# 3봉에서 가장 나은 전략 상세
|
||||
best_label = max(res_3, key=lambda k: sum(
|
||||
PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in res_3[k]))
|
||||
print_detail(f"3봉 최적: {best_label}", res_3[best_label])
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
281
archive/tests/sim_cascade.py
Normal file
281
archive/tests/sim_cascade.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""캐스케이드 limit 주문 전략 시뮬 (30일).
|
||||
|
||||
전략:
|
||||
① bars[0:2] → 2봉, +2% limit (trail 없음)
|
||||
② bars[2:5] → 3봉, +1% limit (trail 없음)
|
||||
③ bars[5:5+last_n] → last_n봉, +0.5% limit (trail 없음)
|
||||
④ bars[5+last_n:] → 기존전략 (TP2% + ATR Trail Stop)
|
||||
"""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
LOOKBACK_DAYS = 30
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
VOL_MIN = 8.0
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
SIGNAL_SQL = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
pc1,po1,pc2,po2,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2
|
||||
AND close_p>pc1 AND pc1>pc2
|
||||
AND vr0>vr1 AND vr1>vr2
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
def fetch_signals(cur, warmup_since, check_since):
|
||||
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
|
||||
rows = cur.fetchall()
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{'t': ticker, 'sig': sig_ts}
|
||||
)
|
||||
er = cur.fetchone()
|
||||
if not er:
|
||||
continue
|
||||
ep, ets = float(er[0]), er[1]
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
return signals
|
||||
|
||||
|
||||
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
|
||||
"""기본 전략: TP + Trail Stop."""
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp = ep * (1 + tp_r)
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if hp >= tp:
|
||||
return dict(status='TP2%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_cascade(bars, ep, ar, last_n):
|
||||
"""
|
||||
① bars[0:2] → 2봉, +2% limit
|
||||
② bars[2:5] → 3봉, +1% limit
|
||||
③ bars[5:5+last_n] → last_n봉, +0.5% limit
|
||||
④ bars[5+last_n:] → 기존전략 (TP2% + Trail Stop)
|
||||
"""
|
||||
stages = [
|
||||
(0, 2, 0.020, f'①2봉2%'),
|
||||
(2, 5, 0.010, f'②3봉1%'),
|
||||
(5, 5 + last_n, 0.005, f'③{last_n}봉0.5%'),
|
||||
]
|
||||
for start, end, lr, tag in stages:
|
||||
lp = ep * (1 + lr)
|
||||
for i, (ts, cp, hp, _) in enumerate(bars[start:end]):
|
||||
if hp >= lp:
|
||||
return dict(status=tag, exit_ts=ts, exit_price=lp,
|
||||
pnl=lr * 100, held=start + i + 1)
|
||||
|
||||
offset = 5 + last_n
|
||||
fb = sim_tp_trail(bars[offset:] or bars[-1:], ep, ar)
|
||||
fb['held'] += offset
|
||||
fb['status'] = '④기존→' + fb['status']
|
||||
return fb
|
||||
|
||||
|
||||
def sim_limit_then_trail(bars, ep, ar, n_bars=2, limit_r=0.005, tp_r=0.02):
|
||||
"""단순 limit: N봉 내 체결 안되면 TP/Trail."""
|
||||
lp = ep * (1 + limit_r)
|
||||
for i, (ts, cp, hp, _) in enumerate(bars[:n_bars]):
|
||||
if hp >= lp:
|
||||
return dict(status=f'limit{limit_r*100:.1f}%', exit_ts=ts,
|
||||
exit_price=lp, pnl=limit_r * 100, held=i + 1)
|
||||
fb = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
|
||||
fb['held'] += n_bars
|
||||
fb['status'] = '미체결→' + fb['status']
|
||||
return fb
|
||||
|
||||
|
||||
def pos_limit(sim):
|
||||
opens, taken, skipped = [], [], []
|
||||
for r in sim:
|
||||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||||
if len(opens) < MAX_POS:
|
||||
opens.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def krw(r):
|
||||
return PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
|
||||
|
||||
def print_cascade_detail(taken, last_n, label):
|
||||
stage_tags = ['①2봉2%', '②3봉1%', f'③{last_n}봉0.5%']
|
||||
stage_lr = [0.020, 0.010, 0.005]
|
||||
|
||||
print(f"\n{'━'*70}")
|
||||
print(f" {label}")
|
||||
print(f" 총 {len(taken)}건 승률 {sum(1 for r in taken if r['pnl']>0)/len(taken)*100:.0f}% "
|
||||
f"합산 {sum(krw(r) for r in taken):+,.0f}원")
|
||||
print(f"{'━'*70}")
|
||||
|
||||
for tag, lr in zip(stage_tags, stage_lr):
|
||||
grp = [r for r in taken if r['status'] == tag]
|
||||
if not grp:
|
||||
continue
|
||||
total = sum(krw(r) for r in grp)
|
||||
avg = total / len(grp)
|
||||
print(f" ┌─ {tag}: {len(grp):3d}건 avg {avg:+,.0f}원/건 소계 {total:+,.0f}원")
|
||||
|
||||
# ④ 기존전략 하위 분류
|
||||
fb_grp = [r for r in taken if r['status'].startswith('④기존→')]
|
||||
if fb_grp:
|
||||
print(f" └─ ④기존전략 (미체결 후): {len(fb_grp)}건")
|
||||
for sub in ['TP2%', '트레일손절', '타임아웃', '진행중']:
|
||||
sub_grp = [r for r in fb_grp if r['status'].endswith(sub)]
|
||||
if not sub_grp:
|
||||
continue
|
||||
total = sum(krw(r) for r in sub_grp)
|
||||
avg = total / len(sub_grp)
|
||||
print(f" {'▲' if total>0 else '▼'} {sub:8s}: {len(sub_grp):3d}건 "
|
||||
f"avg {avg:+,.0f}원/건 소계 {total:+,.0f}원")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10000
|
||||
|
||||
print(f"=== 캐스케이드 limit 전략 시뮬 ===")
|
||||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
|
||||
|
||||
signals = fetch_signals(cur, warmup_since, check_since)
|
||||
print(f"시그널 {len(signals)}건\n")
|
||||
|
||||
# ── 기준선: 현재전략 ─────────────────────────────────────────────────────
|
||||
base_sim = []
|
||||
for s in signals:
|
||||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
base_sim.append({**s, **r})
|
||||
base_taken, _ = pos_limit(base_sim)
|
||||
base_total = sum(krw(r) for r in base_taken)
|
||||
base_wr = sum(1 for r in base_taken if r['pnl'] > 0) / len(base_taken) * 100
|
||||
|
||||
print(f"{'━'*70}")
|
||||
print(f" [기준] 현재전략 TP2%+Trail: {len(base_taken)}건 "
|
||||
f"승률 {base_wr:.0f}% 합산 {base_total:+,.0f}원")
|
||||
|
||||
# ── 비교: limit 0.5%/2봉 → TP/Trail ─────────────────────────────────────
|
||||
lim_sim = []
|
||||
for s in signals:
|
||||
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
lim_sim.append({**s, **r})
|
||||
lim_taken, _ = pos_limit(lim_sim)
|
||||
lim_total = sum(krw(r) for r in lim_taken)
|
||||
lim_wr = sum(1 for r in lim_taken if r['pnl'] > 0) / len(lim_taken) * 100
|
||||
print(f" [비교] limit 0.5%/2봉→TP/Trail: {len(lim_taken)}건 "
|
||||
f"승률 {lim_wr:.0f}% 합산 {lim_total:+,.0f}원")
|
||||
print(f"{'━'*70}\n")
|
||||
|
||||
# ── 캐스케이드 (15봉 / 30봉) ─────────────────────────────────────────────
|
||||
for last_n in [15, 30]:
|
||||
label = f"cascade ①2봉+2% → ②3봉+1% → ③{last_n}봉+0.5% → ④기존전략"
|
||||
csim = []
|
||||
for s in signals:
|
||||
r = sim_cascade(s['bars'], s['entry_price'], s['atr_raw'], last_n)
|
||||
csim.append({**s, **r})
|
||||
taken, _ = pos_limit(csim)
|
||||
print_cascade_detail(taken, last_n, label)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
265
archive/tests/sim_limit_exit.py
Normal file
265
archive/tests/sim_limit_exit.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""진입 즉시 limit 매도 주문 → N봉 내 미체결 시 TP/Trail 전환 시뮬 (30일).
|
||||
|
||||
전략:
|
||||
1. 3봉 vol가속 시그널 → 진입
|
||||
2. 즉시 limit_price = entry_price × (1 + limit_r) 에 limit 매도 주문
|
||||
3. N봉 안에 high_p >= limit_price → 체결 (limit_price에 청산)
|
||||
4. N봉 안에 미체결 → TP 2% + Trail Stop 으로 전환
|
||||
"""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
LOOKBACK_DAYS = 30
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
VOL_MIN = 8.0
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
SIGNAL_SQL = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
pc1,po1,pc2,po2,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2
|
||||
AND close_p>pc1 AND pc1>pc2
|
||||
AND vr0>vr1 AND vr1>vr2
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
def fetch_signals(cur, warmup_since, check_since):
|
||||
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
|
||||
rows = cur.fetchall()
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{'t': ticker, 'sig': sig_ts}
|
||||
)
|
||||
er = cur.fetchone()
|
||||
if not er:
|
||||
continue
|
||||
ep, ets = float(er[0]), er[1]
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
return signals
|
||||
|
||||
|
||||
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
|
||||
"""기본 전략: TP + Trail Stop."""
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp = ep * (1 + tp_r)
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if hp >= tp:
|
||||
return dict(status=f'TP{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_limit_then_trail(bars, ep, ar, n_bars, limit_r, tp_r=0.02):
|
||||
"""진입 즉시 limit_price에 매도 주문 → N봉 내 체결 안되면 TP/Trail 전환.
|
||||
|
||||
체결 조건: high_p >= limit_price → limit_price에 청산 (실현 가능한 가격)
|
||||
"""
|
||||
limit_price = ep * (1 + limit_r)
|
||||
window = bars[:n_bars]
|
||||
|
||||
for i, (ts, cp, hp, lp) in enumerate(window):
|
||||
if hp >= limit_price:
|
||||
pnl = (limit_price - ep) / ep * 100
|
||||
return dict(status=f'limit체결({n_bars}봉)', exit_ts=ts,
|
||||
exit_price=limit_price, pnl=pnl, held=i + 1)
|
||||
|
||||
# N봉 내 미체결 → TP/Trail 전환
|
||||
fallback = sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
|
||||
fallback['status'] = f'미체결→{fallback["status"]}'
|
||||
fallback['held'] += n_bars
|
||||
return fallback
|
||||
|
||||
|
||||
def pos_limit(sim):
|
||||
opens, taken, skipped = [], [], []
|
||||
for r in sim:
|
||||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||||
if len(opens) < MAX_POS:
|
||||
opens.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def stats(taken):
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
return None
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
losses = sum(1 for r in taken if r['pnl'] < 0)
|
||||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||||
avg_h = sum(r['held'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
|
||||
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
|
||||
fill_n = sum(1 for r in taken if 'limit체결' in r['status'])
|
||||
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
|
||||
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0,
|
||||
fill_r=fill_n/n*100)
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10000
|
||||
|
||||
print(f"=== Limit 주문 전략 시뮬 (3봉 진입) ===")
|
||||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
|
||||
|
||||
signals = fetch_signals(cur, warmup_since, check_since)
|
||||
print(f"시그널 {len(signals)}건\n")
|
||||
|
||||
# 비교 전략 목록: (label, limit_r, n_bars)
|
||||
strategies = [
|
||||
('현재전략: TP 2% + Trail', None, None, None),
|
||||
('limit 0.5% / 2봉, 미체결→TP/Trail', 0.005, 2, 0.02),
|
||||
('limit 0.5% / 3봉, 미체결→TP/Trail', 0.005, 3, 0.02),
|
||||
('limit 0.5% / 5봉, 미체결→TP/Trail', 0.005, 5, 0.02),
|
||||
('limit 1.0% / 2봉, 미체결→TP/Trail', 0.010, 2, 0.02),
|
||||
('limit 1.0% / 3봉, 미체결→TP/Trail', 0.010, 3, 0.02),
|
||||
('limit 1.0% / 5봉, 미체결→TP/Trail', 0.010, 5, 0.02),
|
||||
('limit 1.5% / 2봉, 미체결→TP/Trail', 0.015, 2, 0.02),
|
||||
('limit 1.5% / 3봉, 미체결→TP/Trail', 0.015, 3, 0.02),
|
||||
('limit 1.5% / 5봉, 미체결→TP/Trail', 0.015, 5, 0.02),
|
||||
('limit 2.0% / 3봉, 미체결→TP/Trail', 0.020, 3, 0.02),
|
||||
('limit 2.0% / 5봉, 미체결→TP/Trail', 0.020, 5, 0.02),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for label, limit_r, n_bars, tp_r in strategies:
|
||||
sim = []
|
||||
for s in signals:
|
||||
if limit_r is None:
|
||||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
else:
|
||||
r = sim_limit_then_trail(s['bars'], s['entry_price'], s['atr_raw'],
|
||||
n_bars, limit_r, tp_r)
|
||||
sim.append({**s, **r})
|
||||
taken, _ = pos_limit(sim)
|
||||
results[label] = taken
|
||||
|
||||
# ── 요약표 ──────────────────────────────────────────────────────────────────
|
||||
print(f"{'━'*120}")
|
||||
print(f" {'전략':40s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
|
||||
f"{'평균보유':>5s} {'체결률':>5s} {'평균수익':>6s} {'평균손실':>6s}")
|
||||
print(f"{'━'*120}")
|
||||
for label, limit_r, n_bars, tp_r in strategies:
|
||||
taken = results[label]
|
||||
s = stats(taken)
|
||||
if not s:
|
||||
continue
|
||||
print(f" {label:40s} {s['n']:>3d}건 {s['wr']:>4.0f}% {s['total']:>+12,.0f}원 "
|
||||
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}봉 {s['fill_r']:>4.0f}% "
|
||||
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
|
||||
print(f"{'━'*120}")
|
||||
|
||||
# ── 최고 전략 상세 ──────────────────────────────────────────────────────────
|
||||
best_label = max(results, key=lambda k: sum(PER_POS * (r['pnl']/100) - PER_POS*FEE*2
|
||||
for r in results[k]))
|
||||
print(f"\n[최고 전략: {best_label} 건별 상세]")
|
||||
print(f"{'─'*110}")
|
||||
for i, r in enumerate(results[best_label], 1):
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
sign = '▲' if r['pnl'] > 0 else '▼'
|
||||
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
|
||||
f"진입 {r['entry_price']:>10,.0f}원 "
|
||||
f"→ {r['status']:22s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
253
archive/tests/sim_peak_exit.py
Normal file
253
archive/tests/sim_peak_exit.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""진입 후 N봉 내 최고가 청산 전략 시뮬 (30일).
|
||||
|
||||
전략: 3봉 vol가속 진입 → N봉 내 최고 고가에서 매도
|
||||
비교: 현재 전략 (TP 2% + Trail Stop)
|
||||
"""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
LOOKBACK_DAYS = 30
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
VOL_MIN = 8.0
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
SIGNAL_SQL = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
pc1,po1,pc2,po2,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0, vr1, vr2, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2
|
||||
AND close_p>pc1 AND pc1>pc2
|
||||
AND vr0>vr1 AND vr1>vr2
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
def fetch_signals(cur, warmup_since, check_since):
|
||||
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
|
||||
rows = cur.fetchall()
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, vr0, vr1, vr2, atr_raw = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{'t': ticker, 'sig': sig_ts}
|
||||
)
|
||||
er = cur.fetchone()
|
||||
if not er:
|
||||
continue
|
||||
ep, ets = float(er[0]), er[1]
|
||||
# 최대 10봉만 필요 (peak exit용) + trail stop용 241봉
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
return signals
|
||||
|
||||
|
||||
# ── 청산 전략 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def sim_tp_trail(bars, ep, ar, tp_r=0.02):
|
||||
"""기본 전략: TP 2% + Trail Stop."""
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp = ep * (1 + tp_r)
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if hp >= tp:
|
||||
return dict(status='익절+2%', exit_ts=ts, exit_price=tp, pnl=tp_r * 100, held=i + 1)
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_peak_then_trail(bars, ep, ar, n_bars, tp_r=0.02):
|
||||
"""진입 후 n_bars 봉 내 이익 구간이 나오면 고가 청산.
|
||||
이익 없으면 TP 2% + Trail Stop으로 계속 운영.
|
||||
|
||||
이익 판단: n_bars 내 어느 봉이든 high_p > entry_price 이면
|
||||
그 구간의 최고 고가에서 청산.
|
||||
"""
|
||||
window = bars[:n_bars]
|
||||
|
||||
# n봉 내 이익 구간 탐색
|
||||
best_high = max((hp for _, _, hp, _ in window), default=ep)
|
||||
|
||||
if best_high > ep:
|
||||
# 이익 나는 최고 고가에서 청산
|
||||
best_ts = next(ts for ts, cp, hp, lp in window if hp == best_high)
|
||||
held = next(i + 1 for i, (ts, cp, hp, lp) in enumerate(window) if hp == best_high)
|
||||
pnl = (best_high - ep) / ep * 100
|
||||
return dict(status=f'피크청산({n_bars}봉)', exit_ts=best_ts, exit_price=best_high,
|
||||
pnl=pnl, held=held)
|
||||
|
||||
# n봉 내 이익 없음 → TP 2% + Trail Stop으로 전환
|
||||
return sim_tp_trail(bars[n_bars:] or bars[-1:], ep, ar, tp_r)
|
||||
|
||||
|
||||
def pos_limit(sim):
|
||||
opens, taken, skipped = [], [], []
|
||||
for r in sim:
|
||||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||||
if len(opens) < MAX_POS:
|
||||
opens.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def stats(taken):
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
return None
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
losses = sum(1 for r in taken if r['pnl'] < 0)
|
||||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||||
avg_h = sum(r['held'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
|
||||
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
|
||||
return dict(n=n, wins=wins, wr=wins/n*100, total=total, ret=ret, avg_h=avg_h,
|
||||
avg_w=avg_w, avg_l=avg_l, rr=avg_w/avg_l if avg_l else 0)
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10000
|
||||
|
||||
print(f"=== 3봉 진입 후 N봉 피크청산 전략 시뮬 ===")
|
||||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} (30일)\n")
|
||||
|
||||
signals = fetch_signals(cur, warmup_since, check_since)
|
||||
print(f"시그널 {len(signals)}건\n")
|
||||
|
||||
strategies = [
|
||||
('현재전략: TP 2% + Trail Stop', 'tp_trail', None),
|
||||
('2봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 2 ),
|
||||
('3봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 3 ),
|
||||
('5봉 이익시 피크청산, 아니면 TP/Trail', 'peak', 5 ),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for label, mode, param in strategies:
|
||||
sim = []
|
||||
for s in signals:
|
||||
if mode == 'tp_trail':
|
||||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
else:
|
||||
r = sim_peak_then_trail(s['bars'], s['entry_price'], s['atr_raw'], param)
|
||||
sim.append({**s, **r})
|
||||
taken, _ = pos_limit(sim)
|
||||
results[label] = taken
|
||||
|
||||
# ── 요약표 ──────────────────────────────────────────────────────────────────
|
||||
print(f"{'━'*105}")
|
||||
print(f" {'전략':35s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} "
|
||||
f"{'평균보유':>5s} {'평균수익':>6s} {'평균손실':>6s}")
|
||||
print(f"{'━'*105}")
|
||||
for label, mode, param in strategies:
|
||||
taken = results[label]
|
||||
s = stats(taken)
|
||||
if not s:
|
||||
continue
|
||||
print(f" {label:35s} {s['n']:>3d}건 {s['wr']:>4.0f}% {s['total']:>+12,.0f}원 "
|
||||
f"{s['ret']:>+5.2f}% {s['avg_h']:>5.1f}봉 "
|
||||
f"{s['avg_w']:>+5.2f}% {s['avg_l']:>+5.2f}%")
|
||||
print(f"{'━'*105}")
|
||||
|
||||
# ── 3봉 피크청산 상세 ────────────────────────────────────────────────────────
|
||||
label_3 = '3봉 이익시 피크청산, 아니면 TP/Trail'
|
||||
print(f"\n[{label_3} 건별 상세]")
|
||||
print(f"{'─'*100}")
|
||||
for i, r in enumerate(results[label_3], 1):
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
sign = '▲' if r['pnl'] > 0 else '▼'
|
||||
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
|
||||
f"진입 {r['entry_price']:>10,.0f}원 "
|
||||
f"고가청산 {r['exit_price']:>10,.0f}원 "
|
||||
f"{r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
363
archive/tests/sim_recent_db.py
Normal file
363
archive/tests/sim_recent_db.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""DB 기반 하이브리드 시뮬레이션 (신호=10분봉 / 스탑=1분봉).
|
||||
|
||||
실행 흐름:
|
||||
1. pyupbit에서 10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert
|
||||
2. BACKTEST_OHLCV에서 데이터 로드
|
||||
3. 하이브리드 시뮬 실행
|
||||
- 10분봉 타임인덱스로 신호 감지 / 진입
|
||||
- 각 10분봉 구간 내 1분봉으로 트레일링스탑 체크
|
||||
4. 결과 출력
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
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 time
|
||||
import pyupbit
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import oracledb
|
||||
|
||||
# ── Oracle 연결 ─────────────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
|
||||
# ── 전략 파라미터 ────────────────────────────────────────────────────────────
|
||||
LOCAL_VOL_N = 28
|
||||
QUIET_N = 12
|
||||
QUIET_PCT = 2.0
|
||||
THRESH = 4.8
|
||||
SIGNAL_TO = 48
|
||||
VOL_THRESH = 6.0
|
||||
FNG = 14
|
||||
ATR_N = 28
|
||||
ATR_MULT = 1.5
|
||||
ATR_MIN = 0.010
|
||||
ATR_MAX = 0.020
|
||||
TS_N = 48
|
||||
TIME_STOP_PCT = 3.0
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
FEE = 0.0005
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
|
||||
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',
|
||||
]
|
||||
|
||||
SIM_START = '2026-03-03 00:00:00'
|
||||
SIM_END = '2026-03-04 13:30:00'
|
||||
|
||||
|
||||
# ── BACKTEST_OHLCV 공통 함수 ─────────────────────────────────────────────────
|
||||
|
||||
def upsert_ohlcv(conn, ticker: str, interval_cd: str, df: pd.DataFrame) -> int:
|
||||
"""DataFrame을 BACKTEST_OHLCV에 저장 (기존 TS 스킵). 반환: 신규 삽입 건수."""
|
||||
cur = conn.cursor()
|
||||
min_ts = df.index.min().to_pydatetime()
|
||||
cur.execute(
|
||||
"SELECT ts FROM backtest_ohlcv WHERE ticker=:t AND interval_cd=:iv AND ts >= :since",
|
||||
{"t": ticker, "iv": interval_cd, "since": min_ts}
|
||||
)
|
||||
existing = {r[0] for r in cur.fetchall()}
|
||||
|
||||
new_rows = [
|
||||
(ticker, interval_cd, ts_idx.to_pydatetime(),
|
||||
float(row["open"]), float(row["high"]), float(row["low"]),
|
||||
float(row["close"]), float(row["volume"]))
|
||||
for ts_idx, row in df.iterrows()
|
||||
if ts_idx.to_pydatetime() not in existing
|
||||
]
|
||||
if not new_rows:
|
||||
return 0
|
||||
|
||||
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)",
|
||||
new_rows
|
||||
)
|
||||
conn.commit()
|
||||
return len(new_rows)
|
||||
|
||||
|
||||
def load_from_db(conn, ticker: str, interval_cd: str, since: str):
|
||||
"""BACKTEST_OHLCV에서 ticker의 OHLCV 로드."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT ts, open_p, high_p, low_p, close_p, volume_p "
|
||||
"FROM backtest_ohlcv "
|
||||
"WHERE ticker=:ticker AND interval_cd=:iv "
|
||||
"AND ts >= TO_TIMESTAMP(:since, 'YYYY-MM-DD HH24:MI:SS') "
|
||||
"ORDER BY ts",
|
||||
{"ticker": ticker, "iv": interval_cd, "since": since}
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
df = pd.DataFrame(rows, columns=["ts","open","high","low","close","volume"])
|
||||
df["ts"] = pd.to_datetime(df["ts"])
|
||||
df.set_index("ts", inplace=True)
|
||||
return df.astype(float)
|
||||
|
||||
|
||||
# ── 1단계: DB 업데이트 ───────────────────────────────────────────────────────
|
||||
|
||||
def refresh_db(conn) -> None:
|
||||
"""10분봉(300봉) + 1분봉(2880봉) fetch → BACKTEST_OHLCV upsert."""
|
||||
print("BACKTEST_OHLCV 업데이트 중...", flush=True)
|
||||
total10, total1 = 0, 0
|
||||
for tk in TICKERS:
|
||||
try:
|
||||
# 10분봉
|
||||
df10 = pyupbit.get_ohlcv(tk, interval='minute10', count=300)
|
||||
if df10 is not None and len(df10) >= 10:
|
||||
total10 += upsert_ohlcv(conn, tk, 'minute10', df10)
|
||||
time.sleep(0.12)
|
||||
|
||||
# 1분봉 (2일치 ≈ 2880봉, 내부 자동 페이지네이션)
|
||||
df1 = pyupbit.get_ohlcv(tk, interval='minute1', count=2880)
|
||||
if df1 is not None and len(df1) >= 60:
|
||||
total1 += upsert_ohlcv(conn, tk, 'minute1', df1)
|
||||
time.sleep(0.15)
|
||||
|
||||
print(f" {tk}: 10m={len(df10) if df10 is not None else 0}봉 / "
|
||||
f"1m={len(df1) if df1 is not None else 0}봉", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {tk} 오류: {e}")
|
||||
|
||||
print(f"DB 업데이트 완료: 10분봉 신규 {total10}행 / 1분봉 신규 {total1}행\n", flush=True)
|
||||
|
||||
|
||||
# ── ATR 계산 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def calc_atr(df, i, n=28):
|
||||
start = max(0, i - n)
|
||||
sub = df.iloc[start:i]
|
||||
if len(sub) < 5:
|
||||
return ATR_MIN
|
||||
hi = sub['high'].values
|
||||
lo = sub['low'].values
|
||||
cl = sub['close'].values
|
||||
tr = [max(hi[j]-lo[j], abs(hi[j]-cl[j-1]) if j>0 else 0,
|
||||
abs(lo[j]-cl[j-1]) if j>0 else 0) for j in range(len(sub))]
|
||||
atr_pct = np.mean(tr) / cl[-1] if cl[-1] > 0 else ATR_MIN
|
||||
return max(ATR_MIN, min(ATR_MAX, atr_pct * ATR_MULT))
|
||||
|
||||
|
||||
# ── 하이브리드 시뮬 ──────────────────────────────────────────────────────────
|
||||
|
||||
def run_sim(raw10: dict, raw1: dict) -> None:
|
||||
"""신호=10분봉 / 스탑=1분봉 하이브리드 시뮬."""
|
||||
|
||||
# 10분봉 공통 타임인덱스
|
||||
all_idx10 = None
|
||||
for df in raw10.values():
|
||||
all_idx10 = df.index if all_idx10 is None else all_idx10.union(df.index)
|
||||
all_idx10 = all_idx10.sort_values()
|
||||
mask = (all_idx10 >= SIM_START) & (all_idx10 <= SIM_END)
|
||||
sim_idx10 = all_idx10[mask]
|
||||
|
||||
if len(sim_idx10) == 0:
|
||||
print("시뮬 구간 데이터 없음")
|
||||
return
|
||||
|
||||
print(f"시뮬 구간(10분봉): {sim_idx10[0]} ~ {sim_idx10[-1]} ({len(sim_idx10)}봉)")
|
||||
# 1분봉 커버리지 확인
|
||||
n1m = sum(
|
||||
len(df[(df.index >= SIM_START) & (df.index <= SIM_END)])
|
||||
for df in raw1.values() if len(raw1) > 0
|
||||
)
|
||||
print(f"1분봉 총 {n1m}봉 (스탑 체크용)\n")
|
||||
|
||||
positions = {} # ticker → {buy_price, peak, entry_i, invested, atr_stop}
|
||||
signals = {} # ticker → {price, vol_r, sig_i}
|
||||
trades = []
|
||||
|
||||
for i, ts10 in enumerate(sim_idx10):
|
||||
ts10_prev = sim_idx10[i - 1] if i > 0 else ts10
|
||||
|
||||
# ── 1) 1분봉으로 스탑 체크 ────────────────────────────────────────
|
||||
for tk in list(positions.keys()):
|
||||
pos = positions[tk]
|
||||
# 진입 캔들 종료 전엔 체크 금지
|
||||
# 10분봉 ts=X 는 [X, X+10min) 구간이므로 실제 진입은 X+9:59
|
||||
# → X+10min 이후 1분봉부터 체크
|
||||
entry_candle_end = sim_idx10[pos['entry_i']] + pd.Timedelta(minutes=10)
|
||||
|
||||
if tk not in raw1:
|
||||
# 1분봉 없으면 10분봉 종가로 fallback (단, 진입 다음 봉부터)
|
||||
if ts10 <= sim_idx10[pos['entry_i']]:
|
||||
continue
|
||||
if tk not in raw10 or ts10 not in raw10[tk].index:
|
||||
continue
|
||||
current = float(raw10[tk].loc[ts10, 'close'])
|
||||
_check_stop(pos, tk, current, ts10, i, sim_idx10, trades, positions)
|
||||
continue
|
||||
|
||||
df1 = raw1[tk]
|
||||
# 진입 캔들 종료(entry_candle_end) 이후 + 현재 10분봉 구간 이내
|
||||
mask1 = (df1.index >= entry_candle_end) & (df1.index > ts10_prev) & (df1.index <= ts10)
|
||||
sub1 = df1[mask1]
|
||||
|
||||
for ts1m, row1m in sub1.iterrows():
|
||||
current = float(row1m['close'])
|
||||
if _check_stop(pos, tk, current, ts1m, i, sim_idx10, trades, positions):
|
||||
break # 이미 청산됨
|
||||
|
||||
# ── 2) 신호 만료 ────────────────────────────────────────────────
|
||||
for tk in list(signals.keys()):
|
||||
if i - signals[tk]['sig_i'] > SIGNAL_TO:
|
||||
del signals[tk]
|
||||
|
||||
# ── 3) 신호 감지 + 진입 (10분봉 기준) ──────────────────────────
|
||||
for tk in TICKERS:
|
||||
if tk in positions: continue
|
||||
if len(positions) >= MAX_POS: break
|
||||
if tk not in raw10: continue
|
||||
df10 = raw10[tk]
|
||||
if ts10 not in df10.index: continue
|
||||
loc = df10.index.get_loc(ts10)
|
||||
if loc < LOCAL_VOL_N + QUIET_N + 2: continue
|
||||
|
||||
vol_prev = float(df10['volume'].iloc[loc - 1])
|
||||
vol_avg = float(df10['volume'].iloc[loc - LOCAL_VOL_N - 1:loc - 1].mean())
|
||||
vol_r = vol_prev / vol_avg if vol_avg > 0 else 0.0
|
||||
current = float(df10['close'].iloc[loc])
|
||||
close_qn = float(df10['close'].iloc[loc - QUIET_N])
|
||||
chg = abs(current - close_qn) / close_qn * 100 if close_qn > 0 else 999.0
|
||||
|
||||
if vol_r >= VOL_THRESH and chg < QUIET_PCT:
|
||||
if tk not in signals or vol_r > signals[tk]['vol_r']:
|
||||
signals[tk] = {'price': current, 'vol_r': vol_r, 'sig_i': i}
|
||||
|
||||
if tk in signals:
|
||||
sig_p = signals[tk]['price']
|
||||
move = (current - sig_p) / sig_p * 100
|
||||
if move >= THRESH:
|
||||
atr_stop = calc_atr(df10, loc, ATR_N)
|
||||
positions[tk] = {
|
||||
'buy_price': current, 'peak': current,
|
||||
'entry_i': i, 'invested': PER_POS,
|
||||
'atr_stop': atr_stop,
|
||||
}
|
||||
del signals[tk]
|
||||
|
||||
# ── 미청산 강제 청산 ─────────────────────────────────────────────────────
|
||||
last_ts10 = sim_idx10[-1]
|
||||
for tk, pos in list(positions.items()):
|
||||
if tk not in raw10: continue
|
||||
df10 = raw10[tk]
|
||||
current = float(df10.loc[last_ts10, 'close']) if last_ts10 in df10.index else pos['buy_price']
|
||||
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
|
||||
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
|
||||
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
|
||||
trades.append({
|
||||
'date': last_ts10.date(), 'ticker': tk,
|
||||
'buy_price': pos['buy_price'], 'sell_price': current,
|
||||
'pnl_pct': pnl, 'krw': krw, 'reason': '미청산(현재가)',
|
||||
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': last_ts10,
|
||||
})
|
||||
|
||||
# ── 결과 출력 ────────────────────────────────────────────────────────────
|
||||
if not trades:
|
||||
print("거래 없음")
|
||||
return
|
||||
|
||||
import collections
|
||||
by_date = collections.defaultdict(list)
|
||||
for t in trades:
|
||||
by_date[t['date']].append(t)
|
||||
|
||||
total_krw, total_wins = 0, 0
|
||||
for date in sorted(by_date.keys()):
|
||||
day_trades = by_date[date]
|
||||
day_krw = sum(t['krw'] for t in day_trades)
|
||||
day_wins = sum(1 for t in day_trades if t['pnl_pct'] > 0)
|
||||
total_krw += day_krw
|
||||
total_wins += day_wins
|
||||
print(f"\n{'='*62}")
|
||||
print(f"【{date}】 {len(day_trades)}건 | 승률={day_wins/len(day_trades)*100:.0f}% | 일손익={day_krw:+,.0f}원")
|
||||
print(f"{'='*62}")
|
||||
for t in sorted(day_trades, key=lambda x: x['entry_ts']):
|
||||
e = '✅' if t['pnl_pct'] > 0 else '❌'
|
||||
print(f" {e} {t['ticker']:12s} "
|
||||
f"매수={t['buy_price']:,.0f} @ {str(t['entry_ts'])[5:16]} → "
|
||||
f"매도={t['sell_price']:,.0f} @ {str(t['exit_ts'])[5:16]} "
|
||||
f"| {t['pnl_pct']:+.1f}% ({t['krw']:+,.0f}원) [{t['reason']}]")
|
||||
|
||||
print(f"\n{'='*62}")
|
||||
print(f"【2일 합계】 {len(trades)}건 | "
|
||||
f"승률={total_wins/len(trades)*100:.0f}% | 총손익={total_krw:+,.0f}원")
|
||||
print(f" [1분봉 스탑 시뮬] VOL≥{VOL_THRESH}x + 횡보<{QUIET_PCT}% → +{THRESH}% 진입 (F&G={FNG})")
|
||||
|
||||
|
||||
def _check_stop(pos, tk, current, ts, i, sim_idx10, trades, positions) -> bool:
|
||||
"""스탑 체크. 청산 시 True 반환."""
|
||||
if current > pos['peak']:
|
||||
pos['peak'] = current
|
||||
age = i - pos['entry_i']
|
||||
pnl = (current - pos['buy_price']) / pos['buy_price'] * 100
|
||||
peak_drop = (pos['peak'] - current) / pos['peak'] * 100
|
||||
atr_stop = pos['atr_stop']
|
||||
reason = None
|
||||
if peak_drop >= atr_stop * 100:
|
||||
reason = f"트레일링스탑({atr_stop*100:.1f}%)"
|
||||
elif age >= TS_N and pnl < TIME_STOP_PCT:
|
||||
reason = f"타임스탑(+{pnl:.1f}%<{TIME_STOP_PCT}%)"
|
||||
if reason:
|
||||
fee = pos['invested'] * FEE + current * (pos['invested']/pos['buy_price']) * FEE
|
||||
krw = current * (pos['invested']/pos['buy_price']) - pos['invested'] - fee
|
||||
trades.append({
|
||||
'date': ts.date() if hasattr(ts, 'date') else ts,
|
||||
'ticker': tk,
|
||||
'buy_price': pos['buy_price'], 'sell_price': current,
|
||||
'pnl_pct': pnl, 'krw': krw, 'reason': reason,
|
||||
'entry_ts': sim_idx10[pos['entry_i']], 'exit_ts': ts,
|
||||
})
|
||||
del positions[tk]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
conn = _get_conn()
|
||||
|
||||
# 1) DB 업데이트
|
||||
refresh_db(conn)
|
||||
|
||||
# 2) DB에서 데이터 로드
|
||||
print("DB에서 OHLCV 로드 중...", flush=True)
|
||||
LOAD_SINCE = '2026-03-01 00:00:00'
|
||||
raw10, raw1 = {}, {}
|
||||
for tk in TICKERS:
|
||||
df10 = load_from_db(conn, tk, 'minute10', LOAD_SINCE)
|
||||
if df10 is not None and len(df10) > LOCAL_VOL_N + QUIET_N:
|
||||
raw10[tk] = df10
|
||||
|
||||
df1 = load_from_db(conn, tk, 'minute1', LOAD_SINCE)
|
||||
if df1 is not None and len(df1) > 60:
|
||||
raw1[tk] = df1
|
||||
|
||||
print(f"로드 완료: 10분봉 {len(raw10)}종목 / 1분봉 {len(raw1)}종목\n")
|
||||
conn.close()
|
||||
|
||||
# 3) 시뮬 실행
|
||||
print("="*62)
|
||||
print(f"하이브리드 시뮬 (신호=10분봉 / 스탑=1분봉) | 2026-03-03 ~ 03-04")
|
||||
print("="*62)
|
||||
run_sim(raw10, raw1)
|
||||
244
archive/tests/sim_tp_sl.py
Normal file
244
archive/tests/sim_tp_sl.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""TP + 고정 손절 비율 비교 시뮬 (30일)."""
|
||||
import sys, os
|
||||
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 oracledb
|
||||
|
||||
LOOKBACK_DAYS = 30
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
VOL_MIN = 8.0
|
||||
ATR_MULT = 1.0
|
||||
ATR_MIN_R = 0.030
|
||||
ATR_MAX_R = 0.050
|
||||
MAX_TRAIL_BARS = 240
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
FEE = 0.0005
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
SIGNAL_SQL = f"""
|
||||
WITH base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts) pc1,
|
||||
LAG(open_p,1) OVER (PARTITION BY ticker ORDER BY ts) po1,
|
||||
LAG(close_p,2) OVER (PARTITION BY ticker ORDER BY ts) pc2,
|
||||
LAG(open_p,2) OVER (PARTITION BY ticker ORDER BY ts) po2,
|
||||
LAG(close_p,3) OVER (PARTITION BY ticker ORDER BY ts) pc3,
|
||||
LAG(open_p,3) OVER (PARTITION BY ticker ORDER BY ts) po3,
|
||||
GREATEST(high_p-low_p,
|
||||
ABS(high_p-LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p -LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd='minute1'
|
||||
AND ts >= TO_TIMESTAMP(:ws,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
ind AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p,
|
||||
volume_p / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr0,
|
||||
LAG(volume_p,1) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr1,
|
||||
LAG(volume_p,2) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr2,
|
||||
LAG(volume_p,3) OVER (PARTITION BY ticker ORDER BY ts) / NULLIF(AVG(volume_p) OVER (
|
||||
PARTITION BY ticker ORDER BY ts ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING),0) vr3,
|
||||
pc1,po1,pc2,po2,pc3,po3,
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING) / NULLIF(pc1,0) atr_raw
|
||||
FROM base
|
||||
)
|
||||
SELECT ticker, ts, vr0,vr1,vr2,vr3, atr_raw
|
||||
FROM ind
|
||||
WHERE ts >= TO_TIMESTAMP(:cs,'YYYY-MM-DD HH24:MI:SS')
|
||||
AND vr0 >= {VOL_MIN}
|
||||
AND close_p>open_p AND pc1>po1 AND pc2>po2 AND pc3>po3
|
||||
AND close_p>pc1 AND pc1>pc2 AND pc2>pc3
|
||||
AND vr0>vr1 AND vr1>vr2 AND vr2>vr3
|
||||
ORDER BY ticker, ts
|
||||
"""
|
||||
|
||||
|
||||
def sim_trail(bars, ep, ar):
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_tp_trail(bars, ep, ar, tp_r):
|
||||
stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
|
||||
tp = ep * (1 + tp_r)
|
||||
peak = ep
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
if hp >= tp:
|
||||
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
peak = max(peak, cp)
|
||||
if (peak - cp) / peak >= stop:
|
||||
return dict(status='트레일손절', exit_ts=ts, exit_price=cp,
|
||||
pnl=(cp - ep) / ep * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def sim_tp_sl(bars, ep, tp_r, sl_r):
|
||||
"""고정 익절 + 고정 손절. 같은 봉에서 둘 다 터치하면 손절 우선."""
|
||||
tp = ep * (1 + tp_r)
|
||||
sl = ep * (1 - sl_r)
|
||||
for i, (ts, cp, hp, lp) in enumerate(bars):
|
||||
hit_sl = lp <= sl
|
||||
hit_tp = hp >= tp
|
||||
if hit_sl:
|
||||
return dict(status=f'손절-{sl_r*100:.0f}%', exit_ts=ts, exit_price=sl,
|
||||
pnl=-sl_r * 100, held=i + 1)
|
||||
if hit_tp:
|
||||
return dict(status=f'익절+{tp_r*100:.0f}%', exit_ts=ts, exit_price=tp,
|
||||
pnl=tp_r * 100, held=i + 1)
|
||||
lts, lcp = bars[-1][0], bars[-1][1]
|
||||
return dict(status='타임아웃' if len(bars) >= MAX_TRAIL_BARS else '진행중',
|
||||
exit_ts=lts, exit_price=lcp, pnl=(lcp - ep) / ep * 100, held=len(bars))
|
||||
|
||||
|
||||
def pos_limit(sim):
|
||||
opens, taken, skipped = [], [], []
|
||||
for r in sim:
|
||||
opens = [ex for ex in opens if ex > r['entry_ts']]
|
||||
if len(opens) < MAX_POS:
|
||||
opens.append(r['exit_ts'])
|
||||
taken.append(r)
|
||||
else:
|
||||
skipped.append(r)
|
||||
return taken, skipped
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
check_since = (now - timedelta(days=LOOKBACK_DAYS)).strftime('%Y-%m-%d 00:00:00')
|
||||
warmup_since = (now - timedelta(days=LOOKBACK_DAYS + 1)).strftime('%Y-%m-%d 00:00:00')
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 10000
|
||||
|
||||
cur.execute(SIGNAL_SQL, {'ws': warmup_since, 'cs': check_since})
|
||||
rows = cur.fetchall()
|
||||
|
||||
signals = []
|
||||
for row in rows:
|
||||
ticker, sig_ts, vr0, vr1, vr2, vr3, atr_raw = row
|
||||
cur.execute(
|
||||
"""SELECT close_p, ts FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts > :sig AND ts <= :sig + INTERVAL '3' MINUTE
|
||||
ORDER BY ts FETCH FIRST 1 ROWS ONLY""",
|
||||
{'t': ticker, 'sig': sig_ts}
|
||||
)
|
||||
er = cur.fetchone()
|
||||
if not er:
|
||||
continue
|
||||
ep, ets = float(er[0]), er[1]
|
||||
cur.execute(
|
||||
"""SELECT ts, close_p, high_p, low_p FROM backtest_ohlcv
|
||||
WHERE ticker=:t AND interval_cd='minute1'
|
||||
AND ts >= :entry ORDER BY ts FETCH FIRST :n ROWS ONLY""",
|
||||
{'t': ticker, 'entry': ets, 'n': MAX_TRAIL_BARS + 1}
|
||||
)
|
||||
bars = [(r[0], float(r[1]), float(r[2]), float(r[3])) for r in cur.fetchall()]
|
||||
if not bars:
|
||||
continue
|
||||
signals.append({
|
||||
'ticker': ticker, 'entry_ts': ets, 'entry_price': ep,
|
||||
'atr_raw': float(atr_raw) if atr_raw else 0.0,
|
||||
'bars': bars,
|
||||
})
|
||||
|
||||
signals.sort(key=lambda x: x['entry_ts'])
|
||||
print(f"=== TP / 손절 비율 비교 시뮬 ===")
|
||||
print(f"기간: {check_since[:10]} ~ {now.strftime('%Y-%m-%d')} | 시그널 {len(signals)}건\n")
|
||||
|
||||
strategies = [
|
||||
('A. Trail Stop [3~5%]', 'trail', None, None ),
|
||||
('B. TP 3% + Trail Stop', 'tp_trail', 0.03, None ),
|
||||
('C. TP 2% + SL 2%', 'tp_sl', 0.02, 0.02 ),
|
||||
('D. TP 2% + SL 1.5%', 'tp_sl', 0.02, 0.015),
|
||||
('E. TP 2% + SL 1%', 'tp_sl', 0.02, 0.010),
|
||||
('F. TP 3% + SL 2%', 'tp_sl', 0.03, 0.02 ),
|
||||
('G. TP 3% + SL 3%', 'tp_sl', 0.03, 0.03 ),
|
||||
]
|
||||
|
||||
print(f"{'━'*105}")
|
||||
print(f" {'전략':32s} {'거래':>3s} {'승률':>4s} {'합산손익':>12s} {'수익률':>5s} {'평균보유':>5s} {'손익비':>5s}")
|
||||
print(f"{'━'*105}")
|
||||
|
||||
all_results = {}
|
||||
for label, mode, tp_r, sl_r in strategies:
|
||||
sim = []
|
||||
for s in signals:
|
||||
if mode == 'trail':
|
||||
r = sim_trail(s['bars'], s['entry_price'], s['atr_raw'])
|
||||
elif mode == 'tp_trail':
|
||||
r = sim_tp_trail(s['bars'], s['entry_price'], s['atr_raw'], tp_r)
|
||||
else:
|
||||
r = sim_tp_sl(s['bars'], s['entry_price'], tp_r, sl_r)
|
||||
sim.append({**s, **r})
|
||||
taken, _ = pos_limit(sim)
|
||||
all_results[label] = taken
|
||||
n = len(taken)
|
||||
if n == 0:
|
||||
print(f" {label:32s} 거래없음")
|
||||
continue
|
||||
wins = sum(1 for r in taken if r['pnl'] > 0)
|
||||
losses = sum(1 for r in taken if r['pnl'] < 0)
|
||||
total = sum(PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2 for r in taken)
|
||||
avg_h = sum(r['held'] for r in taken) / n
|
||||
ret = total / BUDGET * 100
|
||||
avg_w = sum(r['pnl'] for r in taken if r['pnl'] > 0) / wins if wins else 0
|
||||
avg_l = abs(sum(r['pnl'] for r in taken if r['pnl'] < 0) / losses) if losses else 1
|
||||
rr = avg_w / avg_l
|
||||
print(f" {label:32s} {n:>3d}건 {wins/n*100:>4.0f}% {total:>+12,.0f}원 "
|
||||
f"{ret:>+5.2f}% {avg_h:>5.0f}봉 {rr:>4.1f}:1")
|
||||
print(f"{'━'*105}")
|
||||
|
||||
# ── C 상세 (TP2%+SL2%) ────────────────────────────────────────────────────
|
||||
label_c = 'C. TP 2% + SL 2%'
|
||||
print(f"\n[{label_c} 건별 상세]")
|
||||
print(f"{'─'*100}")
|
||||
for i, r in enumerate(all_results[label_c], 1):
|
||||
krw = PER_POS * (r['pnl'] / 100) - PER_POS * FEE * 2
|
||||
sign = '▲' if r['pnl'] > 0 else '▼'
|
||||
print(f" #{i:02d} {r['ticker']:12s}[{sign}] {str(r['entry_ts'])[:16]} "
|
||||
f"→ {r['status']:14s} {r['held']:3d}봉 {r['pnl']:>+.2f}% ({krw:>+,.0f}원)")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
310
archive/tests/sweep_1min.py
Normal file
310
archive/tests/sweep_1min.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""1분봉 연속 상승 vol spike 전략 파라미터 스윕.
|
||||
|
||||
시그널 조건:
|
||||
봉[n-1]: vol_ratio >= VOL_MIN, 양봉 (close > open)
|
||||
봉[n] : vol_ratio >= VOL_MIN, 양봉, close > 봉[n-1].close (연속 상승)
|
||||
진입: 봉[n] 다음 봉 (봉[n+1]) close에서 즉시
|
||||
추적: 1분봉 trail stop (ATR 기반) + time stop
|
||||
DB 계산: 지표·시그널·진입·running_peak 모두 Oracle SQL / 월별 배치
|
||||
"""
|
||||
import sys, os, itertools
|
||||
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 pandas as pd
|
||||
import oracledb
|
||||
import time as _time
|
||||
|
||||
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
|
||||
VOL_LOOKBACK = 61 # vol_ratio 기준: 이전 60봉 평균
|
||||
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
||||
TS_N = 240 # 타임스탑 봉수 (240분 = 4시간)
|
||||
TIME_STOP_PCT = 0.0 / 100
|
||||
FEE = 0.0005
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
|
||||
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
|
||||
SWEEP = {
|
||||
"VOL": [15.0, 20.0, 25.0, 30.0, 40.0, 50.0], # 시그널 거래량 배율 (상위 범위 탐색)
|
||||
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
|
||||
"ATR_MIN": [0.005, 0.010, 0.015],
|
||||
"ATR_MAX": [0.020, 0.025, 0.030],
|
||||
}
|
||||
VOL_MIN = min(SWEEP["VOL"]) # SQL pre-filter (15x 이상만 로드)
|
||||
|
||||
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
|
||||
SIM_START = datetime(2025, 8, 1)
|
||||
SIM_END = datetime(2026, 3, 4)
|
||||
WARMUP_MINS = 120
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
def _months(start: datetime, end: datetime):
|
||||
m = start.replace(day=1)
|
||||
while m < end:
|
||||
nxt = (m + timedelta(days=32)).replace(day=1)
|
||||
if nxt > end:
|
||||
nxt = end
|
||||
yield m, nxt
|
||||
m = nxt
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── 핵심 SQL ──────────────────────────────────────────────────────────────────
|
||||
# 연속 2봉 vol spike + 상승 확인 후 다음 봉 즉시 진입
|
||||
TRADE_SQL = f"""
|
||||
WITH
|
||||
-- 1) 1분봉 + TR + 이전 봉 정보
|
||||
base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
LAG(close_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_close,
|
||||
LAG(open_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_open,
|
||||
LAG(volume_p, 1) OVER (PARTITION BY ticker ORDER BY ts) prev_volume,
|
||||
GREATEST(
|
||||
high_p - low_p,
|
||||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||||
) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd = 'minute1'
|
||||
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
-- 2) 지표: vol_ratio (현재봉), prev_vol_ratio (이전봉), atr_raw
|
||||
indicators AS (
|
||||
SELECT ticker, ts, open_p, close_p, prev_close, prev_open,
|
||||
-- 현재 봉 vol_ratio
|
||||
volume_p / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vol_ratio,
|
||||
-- 이전 봉 vol_ratio (LAG로 한 봉 앞)
|
||||
prev_volume / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) prev_vol_ratio,
|
||||
-- ATR
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||||
/ NULLIF(prev_close, 0) atr_raw
|
||||
FROM base
|
||||
),
|
||||
-- 3) 연속 2봉 조건:
|
||||
-- 봉[n-1]: prev_vol_ratio >= min_vol, 이전 봉 양봉
|
||||
-- 봉[n] : vol_ratio >= min_vol, 양봉, close > prev_close (상승 지속)
|
||||
signals AS (
|
||||
SELECT ticker, ts sig_ts, close_p sig_price, vol_ratio, atr_raw
|
||||
FROM indicators
|
||||
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
-- 현재 봉 조건
|
||||
AND vol_ratio >= :min_vol
|
||||
AND close_p > open_p
|
||||
-- 이전 봉 조건
|
||||
AND prev_vol_ratio >= :min_vol
|
||||
AND prev_close > prev_open
|
||||
-- 연속 상승
|
||||
AND close_p > prev_close
|
||||
),
|
||||
-- 4) 진입: 시그널 다음 1분봉 즉시
|
||||
entry_cands AS (
|
||||
SELECT
|
||||
s.ticker, s.sig_ts, s.sig_price, s.vol_ratio, s.atr_raw,
|
||||
e.ts entry_ts,
|
||||
e.close_p entry_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
|
||||
FROM signals s
|
||||
JOIN backtest_ohlcv e
|
||||
ON e.ticker = s.ticker
|
||||
AND e.interval_cd = 'minute1'
|
||||
AND e.ts > s.sig_ts
|
||||
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
|
||||
),
|
||||
-- 5) 첫 봉만
|
||||
entries AS (
|
||||
SELECT ticker, sig_ts, sig_price, vol_ratio, atr_raw,
|
||||
entry_ts, entry_price
|
||||
FROM entry_cands WHERE rn = 1
|
||||
),
|
||||
-- 6) 진입 후 TS_N분 1분봉 + 롤링 피크
|
||||
post_entry AS (
|
||||
SELECT
|
||||
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
|
||||
e.vol_ratio, e.atr_raw,
|
||||
b.close_p bar_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
|
||||
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
|
||||
ORDER BY b.ts
|
||||
ROWS UNBOUNDED PRECEDING) running_peak
|
||||
FROM entries e
|
||||
JOIN backtest_ohlcv b
|
||||
ON b.ticker = e.ticker
|
||||
AND b.interval_cd = 'minute1'
|
||||
AND b.ts >= e.entry_ts
|
||||
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
|
||||
)
|
||||
SELECT ticker, sig_ts, entry_ts, entry_price,
|
||||
vol_ratio, atr_raw,
|
||||
bar_n, bar_price, running_peak
|
||||
FROM post_entry
|
||||
WHERE bar_n <= :ts_n + 1
|
||||
ORDER BY ticker, entry_ts, bar_n
|
||||
"""
|
||||
|
||||
|
||||
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
|
||||
print(f"연속 2봉 vol spike 전략 (VOL>={VOL_MIN}x, 연속 상승 후 즉시 진입)\n", flush=True)
|
||||
print("월별 DB 로드...\n", flush=True)
|
||||
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 100_000
|
||||
|
||||
ENTRIES: dict = {}
|
||||
t_load = _time.time()
|
||||
|
||||
for m_start, m_end in _months(SIM_START, SIM_END):
|
||||
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
t0 = _time.time()
|
||||
cur.execute(TRADE_SQL, {
|
||||
"load_since": load_since,
|
||||
"sim_start": sim_start,
|
||||
"sim_end": sim_end,
|
||||
"min_vol": VOL_MIN,
|
||||
"ts_n": TS_N,
|
||||
})
|
||||
rows = cur.fetchall()
|
||||
t1 = _time.time()
|
||||
|
||||
n_new = 0
|
||||
for row in rows:
|
||||
(ticker, sig_ts, entry_ts, entry_price,
|
||||
vol_ratio, atr_raw,
|
||||
bar_n, bar_price, running_peak) = row
|
||||
|
||||
key = (ticker, entry_ts)
|
||||
if key not in ENTRIES:
|
||||
ENTRIES[key] = {
|
||||
'entry_price': float(entry_price),
|
||||
'vol_ratio': float(vol_ratio),
|
||||
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
|
||||
'bars': [],
|
||||
}
|
||||
n_new += 1
|
||||
ENTRIES[key]['bars'].append((float(bar_price), float(running_peak)))
|
||||
|
||||
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True)
|
||||
|
||||
conn.close()
|
||||
print(f"\n총 진입 이벤트: {len(ENTRIES):,}건 | 로드 {_time.time()-t_load:.1f}s\n", flush=True)
|
||||
|
||||
|
||||
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
|
||||
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
|
||||
for n, (bp, pk) in enumerate(bars):
|
||||
drop = (pk - bp) / pk if pk > 0 else 0.0
|
||||
pnl = (bp - entry_price) / entry_price
|
||||
if drop >= atr_stop:
|
||||
return pnl * 100
|
||||
if n + 1 >= TS_N and pnl < TIME_STOP_PCT:
|
||||
return pnl * 100
|
||||
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
|
||||
|
||||
|
||||
# ── 스윕 ──────────────────────────────────────────────────────────────────────
|
||||
ENTRY_LIST = list(ENTRIES.values())
|
||||
keys = list(SWEEP.keys())
|
||||
combos = list(itertools.product(*SWEEP.values()))
|
||||
print(f"총 {len(combos)}가지 조합 스윕...\n", flush=True)
|
||||
|
||||
t_sweep = _time.time()
|
||||
results = []
|
||||
|
||||
for combo in combos:
|
||||
params = dict(zip(keys, combo))
|
||||
if params['ATR_MIN'] >= params['ATR_MAX']:
|
||||
continue
|
||||
|
||||
vol_thr = params['VOL']
|
||||
atr_mult = params['ATR_MULT']
|
||||
atr_min = params['ATR_MIN']
|
||||
atr_max = params['ATR_MAX']
|
||||
|
||||
trades = []
|
||||
for e in ENTRY_LIST:
|
||||
if e['vol_ratio'] < vol_thr:
|
||||
continue
|
||||
ar = e['atr_raw']
|
||||
atr_s = (atr_min if (ar != ar)
|
||||
else max(atr_min, min(atr_max, ar * atr_mult)))
|
||||
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
|
||||
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
|
||||
trades.append((pnl_pct, krw))
|
||||
|
||||
if not trades:
|
||||
results.append({**params, 'trades': 0, 'wins': 0,
|
||||
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
|
||||
continue
|
||||
|
||||
wins = sum(1 for p, _ in trades if p > 0)
|
||||
results.append({
|
||||
**params,
|
||||
'trades': len(trades),
|
||||
'wins': wins,
|
||||
'win_rate': wins / len(trades) * 100,
|
||||
'avg_pnl': sum(p for p, _ in trades) / len(trades),
|
||||
'total_krw': sum(k for _, k in trades),
|
||||
})
|
||||
|
||||
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
|
||||
|
||||
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
|
||||
df_r = pd.DataFrame(results)
|
||||
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
|
||||
|
||||
print("=" * 100)
|
||||
print(f"{'순위':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
|
||||
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
|
||||
print("=" * 100)
|
||||
for rank, (_, row) in enumerate(df_r.head(20).iterrows(), 1):
|
||||
print(f"{rank:>4} {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
|
||||
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
|
||||
f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% "
|
||||
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원")
|
||||
|
||||
# VOL별 최상위 요약
|
||||
print("\n" + "─" * 75)
|
||||
print(f" {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
|
||||
print("─" * 75)
|
||||
for vol in SWEEP["VOL"]:
|
||||
sub = df_r[df_r['VOL'] == vol]
|
||||
if sub.empty:
|
||||
continue
|
||||
best = sub.iloc[0]
|
||||
print(f" {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% "
|
||||
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 "
|
||||
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
|
||||
357
archive/tests/sweep_nbar.py
Normal file
357
archive/tests/sweep_nbar.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""1분봉 N봉 연속 상승 vol spike 전략 파라미터 스윕.
|
||||
|
||||
N=2/3/4 연속 조건 (양봉 + 상승 + vol spike) 모두 Oracle SQL에서 처리.
|
||||
Python은 ATR 파라미터 + VOL 임계값 스윕만 담당.
|
||||
|
||||
시그널 조건 (N_BARS=N):
|
||||
봉[n-(N-1)]~봉[n]: 모두 vol_ratio >= VOL_MIN, 양봉, 연속 상승
|
||||
진입: 봉[n+1] close 즉시
|
||||
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
|
||||
"""
|
||||
import sys, os, itertools
|
||||
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 pandas as pd
|
||||
import oracledb
|
||||
import time as _time
|
||||
|
||||
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
TS_N = 240
|
||||
TIME_STOP_PCT = 0.0 / 100
|
||||
FEE = 0.0005
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
|
||||
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
|
||||
VOL_SWEEP = [1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 25.0]
|
||||
ATR_SWEEP = {
|
||||
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
|
||||
"ATR_MIN": [0.005, 0.010, 0.015],
|
||||
"ATR_MAX": [0.020, 0.025, 0.030],
|
||||
}
|
||||
N_BARS_LIST = [2, 3, 4]
|
||||
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter (모든 봉 >= 1.5x) (모든 봉에 적용)
|
||||
|
||||
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
|
||||
SIM_START = datetime(2025, 8, 1)
|
||||
SIM_END = datetime(2026, 3, 4)
|
||||
WARMUP_MINS = 120
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
def _months(start: datetime, end: datetime):
|
||||
m = start.replace(day=1)
|
||||
while m < end:
|
||||
nxt = (m + timedelta(days=32)).replace(day=1)
|
||||
if nxt > end:
|
||||
nxt = end
|
||||
yield m, nxt
|
||||
m = nxt
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
|
||||
# 공통 CTE: base(TR + LAG N개), indicators(vol_ratio N개 + ATR)
|
||||
# signals: N봉 연속 조건 모두 SQL에서 처리
|
||||
# vol_ratio 컬럼은 Python VOL 스윕용으로 모두 반환
|
||||
|
||||
def build_sql(n: int) -> str:
|
||||
"""n봉 연속 조건을 SQL로 구현. 반환 컬럼에 vol_ratio 0~n-1 포함."""
|
||||
|
||||
# LAG 컬럼 정의 (base CTE)
|
||||
lag_cols = "\n".join(
|
||||
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
|
||||
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
|
||||
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
|
||||
for i in range(1, n)
|
||||
)
|
||||
|
||||
# indicators CTE: vol_ratio 0 ~ n-1
|
||||
vr_cols = []
|
||||
vr_cols.append(f""" volume_p / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vol_ratio_0,""")
|
||||
for i in range(1, n):
|
||||
vr_cols.append(f""" prev_vol_{i} / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vol_ratio_{i},""")
|
||||
vr_cols_str = "\n".join(vr_cols)
|
||||
|
||||
# pass-through LAG 컬럼 from base → indicators
|
||||
lag_passthrough = "\n".join(
|
||||
f" prev_close_{i}, prev_open_{i},"
|
||||
for i in range(1, n)
|
||||
)
|
||||
|
||||
# signals WHERE 조건: 현재봉 + 이전 n-1봉 all 양봉 + 연속 상승 + vol
|
||||
cond_lines = [
|
||||
" AND vol_ratio_0 >= :min_vol",
|
||||
" AND close_p > open_p", # 현재봉 양봉
|
||||
]
|
||||
for i in range(1, n):
|
||||
cond_lines.append(f" AND vol_ratio_{i} >= :min_vol")
|
||||
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 양봉
|
||||
if i == 1:
|
||||
cond_lines.append(f" AND close_p > prev_close_{i}") # 현재봉 > 1봉전
|
||||
else:
|
||||
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 상승 연속
|
||||
cond_str = "\n".join(cond_lines)
|
||||
|
||||
# SELECT: vol_ratio 0~n-1 반환 (Python VOL 스윕용)
|
||||
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
|
||||
|
||||
return f"""
|
||||
WITH
|
||||
base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
{lag_cols}
|
||||
GREATEST(
|
||||
high_p - low_p,
|
||||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||||
) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd = 'minute1'
|
||||
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
indicators AS (
|
||||
SELECT ticker, ts, open_p, close_p,
|
||||
{lag_passthrough}
|
||||
{vr_cols_str}
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||||
/ NULLIF(prev_close_1, 0) atr_raw
|
||||
FROM base
|
||||
),
|
||||
signals AS (
|
||||
SELECT ticker, ts sig_ts, close_p sig_price,
|
||||
{vr_select}, atr_raw
|
||||
FROM indicators
|
||||
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
{cond_str}
|
||||
),
|
||||
entry_cands AS (
|
||||
SELECT s.ticker, s.sig_ts, {vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
|
||||
e.ts entry_ts, e.close_p entry_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
|
||||
FROM signals s
|
||||
JOIN backtest_ohlcv e
|
||||
ON e.ticker = s.ticker
|
||||
AND e.interval_cd = 'minute1'
|
||||
AND e.ts > s.sig_ts
|
||||
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
|
||||
),
|
||||
entries AS (
|
||||
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
|
||||
FROM entry_cands WHERE rn = 1
|
||||
),
|
||||
post_entry AS (
|
||||
SELECT
|
||||
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
|
||||
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
|
||||
b.close_p bar_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
|
||||
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
|
||||
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
|
||||
FROM entries e
|
||||
JOIN backtest_ohlcv b
|
||||
ON b.ticker = e.ticker
|
||||
AND b.interval_cd = 'minute1'
|
||||
AND b.ts >= e.entry_ts
|
||||
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
|
||||
)
|
||||
SELECT ticker, sig_ts, entry_ts, entry_price,
|
||||
{vr_select}, atr_raw,
|
||||
bar_n, bar_price, running_peak
|
||||
FROM post_entry
|
||||
WHERE bar_n <= :ts_n + 1
|
||||
ORDER BY ticker, entry_ts, bar_n
|
||||
"""
|
||||
|
||||
|
||||
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
|
||||
print(f"N봉 연속 vol spike 전략 (VOL>={VOL_MIN}x, N={N_BARS_LIST})\n", flush=True)
|
||||
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 100_000
|
||||
|
||||
# N별로 별도 딕셔너리
|
||||
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
|
||||
t_load = _time.time()
|
||||
|
||||
for n in N_BARS_LIST:
|
||||
sql = build_sql(n)
|
||||
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
|
||||
n_total = 0
|
||||
|
||||
for m_start, m_end in _months(SIM_START, SIM_END):
|
||||
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
t0 = _time.time()
|
||||
cur.execute(sql, {
|
||||
"load_since": load_since,
|
||||
"sim_start": sim_start,
|
||||
"sim_end": sim_end,
|
||||
"min_vol": VOL_MIN,
|
||||
"ts_n": TS_N,
|
||||
})
|
||||
rows = cur.fetchall()
|
||||
t1 = _time.time()
|
||||
|
||||
n_new = 0
|
||||
for row in rows:
|
||||
# ticker, sig_ts, entry_ts, entry_price, vr0..vr(n-1), atr_raw, bar_n, bar_price, peak
|
||||
ticker = row[0]
|
||||
entry_ts = row[2]
|
||||
entry_price= float(row[3])
|
||||
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
|
||||
atr_raw = row[4 + n]
|
||||
bar_price = float(row[4 + n + 2]) # bar_n is at 4+n+1
|
||||
running_peak = float(row[4 + n + 3])
|
||||
|
||||
key = (ticker, entry_ts)
|
||||
if key not in ALL_ENTRIES[n]:
|
||||
ALL_ENTRIES[n][key] = {
|
||||
'entry_price': entry_price,
|
||||
'vr': vr_vals,
|
||||
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
|
||||
'bars': [],
|
||||
}
|
||||
n_new += 1
|
||||
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
|
||||
|
||||
n_total += n_new
|
||||
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True)
|
||||
|
||||
print(f" → {n}봉 합계: {n_total}건\n", flush=True)
|
||||
|
||||
conn.close()
|
||||
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
|
||||
|
||||
|
||||
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
|
||||
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
|
||||
for i, (bp, pk) in enumerate(bars):
|
||||
drop = (pk - bp) / pk if pk > 0 else 0.0
|
||||
pnl = (bp - entry_price) / entry_price
|
||||
if drop >= atr_stop:
|
||||
return pnl * 100
|
||||
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
|
||||
return pnl * 100
|
||||
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
|
||||
|
||||
|
||||
# ── 스윕 ──────────────────────────────────────────────────────────────────────
|
||||
atr_keys = list(ATR_SWEEP.keys())
|
||||
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
|
||||
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
|
||||
print(f"총 {total_combos}가지 조합 스윕...\n", flush=True)
|
||||
|
||||
t_sweep = _time.time()
|
||||
results = []
|
||||
|
||||
for n in N_BARS_LIST:
|
||||
entry_list = list(ALL_ENTRIES[n].values())
|
||||
for vol in VOL_SWEEP:
|
||||
for atr_combo in atr_combos:
|
||||
atr_params = dict(zip(atr_keys, atr_combo))
|
||||
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
|
||||
continue
|
||||
|
||||
atr_mult = atr_params['ATR_MULT']
|
||||
atr_min = atr_params['ATR_MIN']
|
||||
atr_max = atr_params['ATR_MAX']
|
||||
|
||||
trades = []
|
||||
for e in entry_list:
|
||||
# Python: vol 임계값 최종 필터 (모든 봉 vol >= vol)
|
||||
if min(e['vr']) < vol:
|
||||
continue
|
||||
|
||||
ar = e['atr_raw']
|
||||
atr_s = (atr_min if (ar != ar)
|
||||
else max(atr_min, min(atr_max, ar * atr_mult)))
|
||||
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
|
||||
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
|
||||
trades.append((pnl_pct, krw))
|
||||
|
||||
if not trades:
|
||||
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
|
||||
'trades': 0, 'wins': 0,
|
||||
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
|
||||
continue
|
||||
|
||||
wins = sum(1 for p, _ in trades if p > 0)
|
||||
results.append({
|
||||
'N_BARS': n,
|
||||
'VOL': vol,
|
||||
**atr_params,
|
||||
'trades': len(trades),
|
||||
'wins': wins,
|
||||
'win_rate': wins / len(trades) * 100,
|
||||
'avg_pnl': sum(p for p, _ in trades) / len(trades),
|
||||
'total_krw': sum(k for _, k in trades),
|
||||
})
|
||||
|
||||
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
|
||||
|
||||
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
|
||||
df_r = pd.DataFrame(results)
|
||||
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
|
||||
|
||||
print("=" * 105)
|
||||
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
|
||||
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
|
||||
print("=" * 105)
|
||||
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
|
||||
print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.0f}x {row['ATR_MULT']:>6.1f} "
|
||||
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
|
||||
f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% "
|
||||
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원")
|
||||
|
||||
# N봉 × VOL별 최상위 요약
|
||||
print("\n" + "─" * 85)
|
||||
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
|
||||
print("─" * 85)
|
||||
for n in N_BARS_LIST:
|
||||
for vol in VOL_SWEEP:
|
||||
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
|
||||
if sub.empty:
|
||||
continue
|
||||
best = sub.iloc[0]
|
||||
if best['trades'] == 0:
|
||||
continue
|
||||
print(f" {int(n):>2}봉 {vol:>4.0f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% "
|
||||
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 "
|
||||
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
|
||||
print()
|
||||
355
archive/tests/sweep_volaccel.py
Normal file
355
archive/tests/sweep_volaccel.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""1분봉 볼륨 가속 전략 파라미터 스윕.
|
||||
|
||||
시그널 조건: N봉 연속으로 가격 AND 거래량이 함께 증가
|
||||
봉[n-(N-1)] < 봉[n-(N-2)] < ... < 봉[n]
|
||||
- 각 봉: 양봉 (close > open)
|
||||
- 가격 연속 상승: close[k] > close[k-1]
|
||||
- 볼륨 연속 증가: vol_ratio[k] > vol_ratio[k-1]
|
||||
- 현재봉(가장 강한 봉) vol_ratio >= VOL_MIN (pre-filter)
|
||||
|
||||
진입: 봉[n+1] close 즉시
|
||||
추적: 1분봉 trail stop (ATR) + time stop / 월별 배치
|
||||
"""
|
||||
import sys, os, itertools
|
||||
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 pandas as pd
|
||||
import oracledb
|
||||
import time as _time
|
||||
|
||||
# ── 고정 파라미터 ─────────────────────────────────────────────────────────────
|
||||
VOL_LOOKBACK = 61
|
||||
ATR_LOOKBACK = 28
|
||||
TS_N = 240
|
||||
TIME_STOP_PCT = 0.0 / 100
|
||||
FEE = 0.0005
|
||||
BUDGET = 15_000_000
|
||||
MAX_POS = 3
|
||||
PER_POS = BUDGET // MAX_POS
|
||||
|
||||
# ── 스윕 파라미터 ─────────────────────────────────────────────────────────────
|
||||
# 현재봉(가장 강한 봉)의 vol_ratio 최솟값 — 가속의 "끝점" 기준
|
||||
VOL_SWEEP = [2.0, 3.0, 4.0, 5.0, 8.0, 10.0, 15.0]
|
||||
ATR_SWEEP = {
|
||||
"ATR_MULT": [1.5, 2.0, 2.5, 3.0],
|
||||
"ATR_MIN": [0.005, 0.010, 0.015],
|
||||
"ATR_MAX": [0.020, 0.025, 0.030],
|
||||
}
|
||||
N_BARS_LIST = [2, 3, 4]
|
||||
VOL_MIN = min(VOL_SWEEP) # SQL pre-filter
|
||||
|
||||
# ── 시뮬 구간 ─────────────────────────────────────────────────────────────────
|
||||
SIM_START = datetime(2025, 8, 1)
|
||||
SIM_END = datetime(2026, 3, 4)
|
||||
WARMUP_MINS = 120
|
||||
|
||||
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',
|
||||
]
|
||||
_TK = ",".join(f"'{t}'" for t in TICKERS)
|
||||
|
||||
|
||||
def _months(start: datetime, end: datetime):
|
||||
m = start.replace(day=1)
|
||||
while m < end:
|
||||
nxt = (m + timedelta(days=32)).replace(day=1)
|
||||
if nxt > end:
|
||||
nxt = end
|
||||
yield m, nxt
|
||||
m = nxt
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── N별 SQL 생성 ──────────────────────────────────────────────────────────────
|
||||
# 조건: 가격 연속 상승 + 볼륨 연속 증가 (+ 양봉)
|
||||
# vol_ratio_0 > vol_ratio_1 > ... > vol_ratio_(n-1)
|
||||
# close_0 > close_1 > ... > close_(n-1)
|
||||
# 모두 Oracle SQL에서 처리
|
||||
|
||||
def build_sql(n: int) -> str:
|
||||
lag_cols = "\n".join(
|
||||
f" LAG(close_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_close_{i},\n"
|
||||
f" LAG(open_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_open_{i},\n"
|
||||
f" LAG(volume_p, {i}) OVER (PARTITION BY ticker ORDER BY ts) prev_vol_{i},"
|
||||
for i in range(1, n)
|
||||
)
|
||||
|
||||
vr_cols = []
|
||||
vr_cols.append(f""" volume_p / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vol_ratio_0,""")
|
||||
for i in range(1, n):
|
||||
vr_cols.append(f""" prev_vol_{i} / NULLIF(
|
||||
AVG(volume_p) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {VOL_LOOKBACK} PRECEDING AND 2 PRECEDING), 0
|
||||
) vol_ratio_{i},""")
|
||||
vr_cols_str = "\n".join(vr_cols)
|
||||
|
||||
lag_passthrough = "\n".join(
|
||||
f" prev_close_{i}, prev_open_{i},"
|
||||
for i in range(1, n)
|
||||
)
|
||||
|
||||
# 조건: 양봉 + 가격 연속 상승 + 볼륨 연속 증가
|
||||
cond_lines = [
|
||||
" AND vol_ratio_0 >= :min_vol", # pre-filter: 현재봉(최강봉)
|
||||
" AND close_p > open_p", # 현재봉 양봉
|
||||
]
|
||||
for i in range(1, n):
|
||||
cond_lines.append(f" AND prev_close_{i} > prev_open_{i}") # 이전봉 양봉
|
||||
if i == 1:
|
||||
cond_lines.append(f" AND close_p > prev_close_{i}") # 가격 상승
|
||||
cond_lines.append(f" AND vol_ratio_0 > vol_ratio_{i}") # 볼륨 증가
|
||||
else:
|
||||
cond_lines.append(f" AND prev_close_{i-1} > prev_close_{i}") # 가격 상승
|
||||
cond_lines.append(f" AND vol_ratio_{i-1} > vol_ratio_{i}") # 볼륨 증가
|
||||
cond_str = "\n".join(cond_lines)
|
||||
|
||||
vr_select = ", ".join(f"vol_ratio_{i}" for i in range(n))
|
||||
|
||||
return f"""
|
||||
WITH
|
||||
base AS (
|
||||
SELECT ticker, ts, open_p, close_p, high_p, low_p, volume_p,
|
||||
{lag_cols}
|
||||
GREATEST(
|
||||
high_p - low_p,
|
||||
ABS(high_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts)),
|
||||
ABS(low_p - LAG(close_p,1) OVER (PARTITION BY ticker ORDER BY ts))
|
||||
) tr
|
||||
FROM backtest_ohlcv
|
||||
WHERE interval_cd = 'minute1'
|
||||
AND ts >= TO_TIMESTAMP(:load_since, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ticker IN ({_TK})
|
||||
),
|
||||
indicators AS (
|
||||
SELECT ticker, ts, open_p, close_p,
|
||||
{lag_passthrough}
|
||||
{vr_cols_str}
|
||||
AVG(tr) OVER (PARTITION BY ticker ORDER BY ts
|
||||
ROWS BETWEEN {ATR_LOOKBACK} PRECEDING AND 1 PRECEDING)
|
||||
/ NULLIF(prev_close_1, 0) atr_raw
|
||||
FROM base
|
||||
),
|
||||
signals AS (
|
||||
SELECT ticker, ts sig_ts, close_p sig_price,
|
||||
{vr_select}, atr_raw
|
||||
FROM indicators
|
||||
WHERE ts >= TO_TIMESTAMP(:sim_start, 'YYYY-MM-DD HH24:MI:SS')
|
||||
AND ts < TO_TIMESTAMP(:sim_end, 'YYYY-MM-DD HH24:MI:SS')
|
||||
{cond_str}
|
||||
),
|
||||
entry_cands AS (
|
||||
SELECT s.ticker, s.sig_ts,
|
||||
{vr_select.replace('vol_ratio_', 's.vol_ratio_')}, s.atr_raw,
|
||||
e.ts entry_ts, e.close_p entry_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY s.ticker, s.sig_ts ORDER BY e.ts) rn
|
||||
FROM signals s
|
||||
JOIN backtest_ohlcv e
|
||||
ON e.ticker = s.ticker
|
||||
AND e.interval_cd = 'minute1'
|
||||
AND e.ts > s.sig_ts
|
||||
AND e.ts <= s.sig_ts + INTERVAL '3' MINUTE
|
||||
),
|
||||
entries AS (
|
||||
SELECT ticker, sig_ts, {vr_select}, atr_raw, entry_ts, entry_price
|
||||
FROM entry_cands WHERE rn = 1
|
||||
),
|
||||
post_entry AS (
|
||||
SELECT
|
||||
e.ticker, e.sig_ts, e.entry_ts, e.entry_price,
|
||||
{vr_select.replace('vol_ratio_', 'e.vol_ratio_')}, e.atr_raw,
|
||||
b.close_p bar_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.ticker, e.entry_ts ORDER BY b.ts) bar_n,
|
||||
MAX(b.close_p) OVER (PARTITION BY e.ticker, e.entry_ts
|
||||
ORDER BY b.ts ROWS UNBOUNDED PRECEDING) running_peak
|
||||
FROM entries e
|
||||
JOIN backtest_ohlcv b
|
||||
ON b.ticker = e.ticker
|
||||
AND b.interval_cd = 'minute1'
|
||||
AND b.ts >= e.entry_ts
|
||||
AND b.ts <= e.entry_ts + INTERVAL '{TS_N}' MINUTE
|
||||
)
|
||||
SELECT ticker, sig_ts, entry_ts, entry_price,
|
||||
{vr_select}, atr_raw,
|
||||
bar_n, bar_price, running_peak
|
||||
FROM post_entry
|
||||
WHERE bar_n <= :ts_n + 1
|
||||
ORDER BY ticker, entry_ts, bar_n
|
||||
"""
|
||||
|
||||
|
||||
# ── 월별 데이터 로드 ──────────────────────────────────────────────────────────
|
||||
print(f"볼륨 가속 전략 (현재봉 VOL>={VOL_MIN}x, N={N_BARS_LIST}, 가격·볼륨 동시 가속)\n", flush=True)
|
||||
|
||||
conn = _get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.arraysize = 100_000
|
||||
|
||||
ALL_ENTRIES: dict[int, dict] = {n: {} for n in N_BARS_LIST}
|
||||
t_load = _time.time()
|
||||
|
||||
for n in N_BARS_LIST:
|
||||
sql = build_sql(n)
|
||||
print(f"── {n}봉 로드 중... ──────────────────────────", flush=True)
|
||||
n_total = 0
|
||||
|
||||
for m_start, m_end in _months(SIM_START, SIM_END):
|
||||
load_since = (m_start - timedelta(minutes=WARMUP_MINS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_start = m_start.strftime('%Y-%m-%d %H:%M:%S')
|
||||
sim_end = m_end.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
t0 = _time.time()
|
||||
cur.execute(sql, {
|
||||
"load_since": load_since,
|
||||
"sim_start": sim_start,
|
||||
"sim_end": sim_end,
|
||||
"min_vol": VOL_MIN,
|
||||
"ts_n": TS_N,
|
||||
})
|
||||
rows = cur.fetchall()
|
||||
t1 = _time.time()
|
||||
|
||||
n_new = 0
|
||||
for row in rows:
|
||||
ticker = row[0]
|
||||
entry_ts = row[2]
|
||||
entry_price = float(row[3])
|
||||
vr_vals = [float(row[4 + i]) if row[4 + i] is not None else 0.0 for i in range(n)]
|
||||
atr_raw = row[4 + n]
|
||||
bar_price = float(row[4 + n + 2])
|
||||
running_peak= float(row[4 + n + 3])
|
||||
|
||||
key = (ticker, entry_ts)
|
||||
if key not in ALL_ENTRIES[n]:
|
||||
ALL_ENTRIES[n][key] = {
|
||||
'entry_price': entry_price,
|
||||
'vr0': vr_vals[0], # 현재봉 vol (가장 강한 봉)
|
||||
'atr_raw': float(atr_raw) if atr_raw is not None else float('nan'),
|
||||
'bars': [],
|
||||
}
|
||||
n_new += 1
|
||||
ALL_ENTRIES[n][key]['bars'].append((bar_price, running_peak))
|
||||
|
||||
n_total += n_new
|
||||
print(f" {sim_start[:7]}: {len(rows):>8,}행 ({t1-t0:.1f}s) | 진입 {n_new:>5}건", flush=True)
|
||||
|
||||
print(f" → {n}봉 합계: {n_total}건\n", flush=True)
|
||||
|
||||
conn.close()
|
||||
print(f"전체 로드 완료 ({_time.time()-t_load:.1f}s)\n", flush=True)
|
||||
|
||||
|
||||
# ── 출구 탐색 ─────────────────────────────────────────────────────────────────
|
||||
def find_exit(entry_price: float, atr_stop: float, bars: list) -> float:
|
||||
for i, (bp, pk) in enumerate(bars):
|
||||
drop = (pk - bp) / pk if pk > 0 else 0.0
|
||||
pnl = (bp - entry_price) / entry_price
|
||||
if drop >= atr_stop:
|
||||
return pnl * 100
|
||||
if i + 1 >= TS_N and pnl < TIME_STOP_PCT:
|
||||
return pnl * 100
|
||||
return (bars[-1][0] - entry_price) / entry_price * 100 if bars else 0.0
|
||||
|
||||
|
||||
# ── 스윕 ──────────────────────────────────────────────────────────────────────
|
||||
atr_keys = list(ATR_SWEEP.keys())
|
||||
atr_combos = list(itertools.product(*ATR_SWEEP.values()))
|
||||
total_combos = len(N_BARS_LIST) * len(VOL_SWEEP) * len(atr_combos)
|
||||
print(f"총 {total_combos}가지 조합 스윕...\n", flush=True)
|
||||
|
||||
t_sweep = _time.time()
|
||||
results = []
|
||||
|
||||
for n in N_BARS_LIST:
|
||||
entry_list = list(ALL_ENTRIES[n].values())
|
||||
for vol in VOL_SWEEP:
|
||||
for atr_combo in atr_combos:
|
||||
atr_params = dict(zip(atr_keys, atr_combo))
|
||||
if atr_params['ATR_MIN'] >= atr_params['ATR_MAX']:
|
||||
continue
|
||||
|
||||
atr_mult = atr_params['ATR_MULT']
|
||||
atr_min = atr_params['ATR_MIN']
|
||||
atr_max = atr_params['ATR_MAX']
|
||||
|
||||
trades = []
|
||||
for e in entry_list:
|
||||
# 현재봉(최강봉) vol 기준으로 필터
|
||||
if e['vr0'] < vol:
|
||||
continue
|
||||
|
||||
ar = e['atr_raw']
|
||||
atr_s = (atr_min if (ar != ar)
|
||||
else max(atr_min, min(atr_max, ar * atr_mult)))
|
||||
pnl_pct = find_exit(e['entry_price'], atr_s, e['bars'])
|
||||
krw = PER_POS * (pnl_pct / 100) - PER_POS * FEE * 2
|
||||
trades.append((pnl_pct, krw))
|
||||
|
||||
if not trades:
|
||||
results.append({'N_BARS': n, 'VOL': vol, **atr_params,
|
||||
'trades': 0, 'wins': 0,
|
||||
'win_rate': 0.0, 'avg_pnl': 0.0, 'total_krw': 0.0})
|
||||
continue
|
||||
|
||||
wins = sum(1 for p, _ in trades if p > 0)
|
||||
results.append({
|
||||
'N_BARS': n,
|
||||
'VOL': vol,
|
||||
**atr_params,
|
||||
'trades': len(trades),
|
||||
'wins': wins,
|
||||
'win_rate': wins / len(trades) * 100,
|
||||
'avg_pnl': sum(p for p, _ in trades) / len(trades),
|
||||
'total_krw': sum(k for _, k in trades),
|
||||
})
|
||||
|
||||
print(f"스윕 완료 ({_time.time()-t_sweep:.1f}s)\n")
|
||||
|
||||
# ── 결과 출력 ─────────────────────────────────────────────────────────────────
|
||||
df_r = pd.DataFrame(results)
|
||||
df_r = df_r[df_r['trades'] > 0].sort_values('total_krw', ascending=False)
|
||||
|
||||
print("=" * 105)
|
||||
print(f"{'순위':>4} {'N봉':>4} {'VOL':>5} {'ATR_M':>6} {'ATR_N':>6} {'ATR_X':>6} "
|
||||
f"{'건수':>5} {'승률':>6} {'평균PNL':>8} {'총손익':>14}")
|
||||
print("=" * 105)
|
||||
for rank, (_, row) in enumerate(df_r.head(30).iterrows(), 1):
|
||||
print(f"{rank:>4} {int(row['N_BARS']):>2}봉 {row['VOL']:>4.1f}x {row['ATR_MULT']:>6.1f} "
|
||||
f"{row['ATR_MIN']*100:>5.1f}% {row['ATR_MAX']*100:>5.1f}% "
|
||||
f"{int(row['trades']):>5}건 {row['win_rate']:>5.0f}% "
|
||||
f"{row['avg_pnl']:>+7.2f}% {row['total_krw']:>+14,.0f}원")
|
||||
|
||||
# N봉 × VOL별 최상위 요약
|
||||
print("\n" + "─" * 85)
|
||||
print(f" {'N봉':>3} {'VOL':>5} {'건수':>5} {'승률':>5} {'평균PNL':>8} {'총손익':>14} (최적 ATR)")
|
||||
print("─" * 85)
|
||||
for n in N_BARS_LIST:
|
||||
for vol in VOL_SWEEP:
|
||||
sub = df_r[(df_r['N_BARS'] == n) & (df_r['VOL'] == vol)]
|
||||
if sub.empty:
|
||||
continue
|
||||
best = sub.iloc[0]
|
||||
if best['trades'] == 0:
|
||||
continue
|
||||
print(f" {int(n):>2}봉 {vol:>4.1f}x {int(best['trades']):>5}건 {best['win_rate']:>4.0f}% "
|
||||
f"{best['avg_pnl']:>+7.2f}% {best['total_krw']:>+14,.0f}원 "
|
||||
f"(M={best['ATR_MULT']:.1f} N={best['ATR_MIN']*100:.1f}% X={best['ATR_MAX']*100:.1f}%)")
|
||||
print()
|
||||
187
archive/tests/test_llm_advisor.py
Normal file
187
archive/tests/test_llm_advisor.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""core/llm_advisor 단위 테스트.
|
||||
|
||||
실행:
|
||||
.venv/bin/python3 -m pytest tests/test_llm_advisor.py -v
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault('ORACLE_USER', 'x')
|
||||
os.environ.setdefault('ORACLE_PASSWORD', 'x')
|
||||
os.environ.setdefault('ORACLE_DSN', 'x')
|
||||
os.environ.setdefault('ANTHROPIC_API_KEY', 'test-key')
|
||||
|
||||
from core.llm_advisor import (
|
||||
get_exit_price,
|
||||
_describe_bars,
|
||||
_build_prompt,
|
||||
_execute_tool,
|
||||
)
|
||||
|
||||
TICKER = 'KRW-XRP'
|
||||
|
||||
|
||||
def _make_bars(n: int = 20, base: float = 2100.0) -> list[dict]:
|
||||
now = datetime.now()
|
||||
bars = []
|
||||
for i in range(n):
|
||||
p = base + i * 0.5
|
||||
bars.append({
|
||||
'open': p,
|
||||
'high': p + 2,
|
||||
'low': p - 2,
|
||||
'close': p + 1,
|
||||
'volume': 100.0,
|
||||
'ts': now - timedelta(seconds=(n - i) * 20),
|
||||
})
|
||||
return bars
|
||||
|
||||
|
||||
def _make_pos(entry: float = 2084.0, seconds_ago: int = 120,
|
||||
sell_price: float = 2126.0) -> dict:
|
||||
return {
|
||||
'entry_price': entry,
|
||||
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
|
||||
'sell_price': sell_price,
|
||||
'sell_uuid': 'uuid-1',
|
||||
'qty': 2399.0,
|
||||
'stage': 0,
|
||||
'llm_last_ts': None,
|
||||
}
|
||||
|
||||
|
||||
def _mock_response(action: str, price: float = 0):
|
||||
"""Anthropic API 응답 Mock."""
|
||||
if action == 'hold':
|
||||
text = '{"action": "hold"}'
|
||||
else:
|
||||
text = json.dumps({'action': 'sell', 'price': price})
|
||||
|
||||
content_block = MagicMock()
|
||||
content_block.type = 'text'
|
||||
content_block.text = text
|
||||
|
||||
response = MagicMock()
|
||||
response.content = [content_block]
|
||||
response.stop_reason = 'end_turn'
|
||||
return response
|
||||
|
||||
|
||||
# ── _describe_bars ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDescribeBars:
|
||||
|
||||
def test_returns_string(self):
|
||||
bars = _make_bars(20, base=2100.0)
|
||||
desc = _describe_bars(bars, current_price=2110.0)
|
||||
assert isinstance(desc, str)
|
||||
assert '패턴 요약' in desc
|
||||
|
||||
def test_empty_bars_returns_fallback(self):
|
||||
desc = _describe_bars([], current_price=2100.0)
|
||||
assert desc == '봉 데이터 없음'
|
||||
|
||||
def test_trend_direction_detected(self):
|
||||
bars = _make_bars(20, base=2100.0) # 상승 bars (base + i*0.5)
|
||||
desc = _describe_bars(bars, current_price=2110.0)
|
||||
assert '상승▲' in desc
|
||||
|
||||
|
||||
# ── get_exit_price: hold ──────────────────────────────────────────────────────
|
||||
|
||||
class TestGetExitPriceHold:
|
||||
|
||||
def test_returns_none_on_hold(self):
|
||||
pos = _make_pos()
|
||||
bar_list = _make_bars()
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.return_value = _mock_response('hold')
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_api_key(self):
|
||||
pos = _make_pos()
|
||||
bar_list = _make_bars()
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}):
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── get_exit_price: sell ─────────────────────────────────────────────────────
|
||||
|
||||
class TestGetExitPriceSell:
|
||||
|
||||
def test_returns_llm_suggested_price(self):
|
||||
pos = _make_pos(entry=2084.0, sell_price=2126.0)
|
||||
bar_list = _make_bars()
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2112.0)
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result == 2112.0
|
||||
|
||||
def test_llm_can_suggest_below_current_price(self):
|
||||
"""LLM의 판단을 신뢰 — 현재가 이하 제안도 그대로 반환."""
|
||||
pos = _make_pos(entry=2084.0, sell_price=2126.0)
|
||||
bar_list = _make_bars()
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2080.0)
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result == 2080.0 # 가드 없음 — LLM 신뢰
|
||||
|
||||
def test_llm_can_suggest_high_price(self):
|
||||
"""LLM의 판단을 신뢰 — 진입가 대비 10% 높은 제안도 그대로 반환."""
|
||||
pos = _make_pos(entry=2084.0, sell_price=2126.0)
|
||||
bar_list = _make_bars()
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2300.0)
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result == 2300.0 # 상한 가드 없음 — LLM 신뢰
|
||||
|
||||
|
||||
# ── get_exit_price: 오류 처리 ─────────────────────────────────────────────────
|
||||
|
||||
class TestGetExitPriceErrors:
|
||||
|
||||
def test_returns_none_on_json_error(self):
|
||||
"""JSON 파싱 실패 → None (cascade fallback)."""
|
||||
pos = _make_pos()
|
||||
bar_list = _make_bars()
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.content = [MagicMock(type='text', text='not json')]
|
||||
bad_resp.stop_reason = 'end_turn'
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.return_value = bad_resp
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_api_exception(self):
|
||||
"""API 오류 → None (cascade fallback)."""
|
||||
pos = _make_pos()
|
||||
bar_list = _make_bars()
|
||||
with patch('anthropic.Anthropic') as MockClient:
|
||||
MockClient.return_value.messages.create.side_effect = Exception('API Error')
|
||||
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── tool 실행 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestExecuteTool:
|
||||
|
||||
def test_unknown_tool_returns_error_string(self):
|
||||
result = _execute_tool('unknown_tool', {'ticker': TICKER})
|
||||
assert '알 수 없는 tool' in result
|
||||
|
||||
def test_get_price_ticks_db_error_returns_string(self):
|
||||
"""DB 연결 실패 시 오류 문자열 반환 (예외 아님)."""
|
||||
result = _execute_tool('get_price_ticks', {'ticker': TICKER, 'minutes': 5})
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_get_ohlcv_db_error_returns_string(self):
|
||||
result = _execute_tool('get_ohlcv', {'ticker': TICKER, 'limit': 10})
|
||||
assert isinstance(result, str)
|
||||
216
archive/tests/test_tick_trader.py
Normal file
216
archive/tests/test_tick_trader.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""tick_trader 핵심 로직 단위 테스트.
|
||||
|
||||
실행:
|
||||
.venv/bin/python3 -m pytest tests/test_tick_trader.py -v
|
||||
|
||||
테스트 대상:
|
||||
- update_positions: Trail Stop 발동 시점 / peak 초기화
|
||||
- _advance_stage: cascade 단계 전환 / trail 전환
|
||||
- check_filled_positions: 체결 확인 / 단계 시간 초과
|
||||
- enter_position: sell_uuid=None(주문실패)일 때 즉시 Trail Stop 방지
|
||||
"""
|
||||
import sys, os
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 환경변수 Mock — 실제 API 키 불필요
|
||||
os.environ.setdefault('ACCESS_KEY', 'test')
|
||||
os.environ.setdefault('SECRET_KEY', 'test')
|
||||
os.environ.setdefault('SIMULATION_MODE', 'true')
|
||||
os.environ.setdefault('MAX_POSITIONS', '3')
|
||||
os.environ.setdefault('MAX_BUDGET', '15000000')
|
||||
os.environ.setdefault('ORACLE_USER', 'x')
|
||||
os.environ.setdefault('ORACLE_PASSWORD', 'x')
|
||||
os.environ.setdefault('ORACLE_DSN', 'x')
|
||||
os.environ.setdefault('TELEGRAM_TRADE_TOKEN', 'x')
|
||||
os.environ.setdefault('TELEGRAM_CHAT_ID', '0')
|
||||
|
||||
# pyupbit 임포트 전 Mock 처리
|
||||
with patch('pyupbit.Upbit'), patch('pyupbit.WebSocketManager'):
|
||||
import importlib
|
||||
import daemons.tick_trader as tt
|
||||
|
||||
TICKER = 'KRW-TEST'
|
||||
|
||||
|
||||
def _make_pos(entry_price=1000.0, seconds_ago=0, sell_uuid='uuid-1', stage=0):
|
||||
"""테스트용 포지션 딕셔너리 생성."""
|
||||
return {
|
||||
'entry_price': entry_price,
|
||||
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
|
||||
'running_peak': entry_price,
|
||||
'qty': 100.0,
|
||||
'stage': stage,
|
||||
'sell_uuid': sell_uuid,
|
||||
'sell_price': entry_price * 1.02,
|
||||
'trail_peak_set': False,
|
||||
}
|
||||
|
||||
|
||||
# ── update_positions ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestUpdatePositions:
|
||||
|
||||
def setup_method(self):
|
||||
tt.positions.clear()
|
||||
|
||||
def test_trail_stop_does_not_fire_before_stage3_end(self):
|
||||
"""③ 종료(300s) 이전에는 sell_uuid=None이어도 Trail Stop 발동 안 함."""
|
||||
tt.positions[TICKER] = _make_pos(
|
||||
entry_price=1000, seconds_ago=10, sell_uuid=None, stage=4
|
||||
)
|
||||
with patch.object(tt, 'do_sell_market') as mock_sell:
|
||||
tt.update_positions({TICKER: 900.0}) # -10% 하락이어도
|
||||
mock_sell.assert_not_called()
|
||||
|
||||
def test_trail_stop_fires_after_stage3_end(self):
|
||||
"""300s 경과 후 peak 대비 0.8% 이상 하락 시 Trail Stop 발동."""
|
||||
tt.positions[TICKER] = _make_pos(
|
||||
entry_price=1000, seconds_ago=310, sell_uuid=None, stage=4
|
||||
)
|
||||
# 300s 이후 peak 설정 — 첫 틱에 running_peak = 1100 초기화
|
||||
tt.positions[TICKER]['trail_peak_set'] = True
|
||||
tt.positions[TICKER]['running_peak'] = 1100.0
|
||||
|
||||
with patch.object(tt, 'do_sell_market', return_value=1091.0) as mock_sell, \
|
||||
patch.object(tt, '_record_exit') as mock_exit:
|
||||
tt.update_positions({TICKER: 1091.0}) # 1100→1091 = -0.82%
|
||||
mock_sell.assert_called_once()
|
||||
mock_exit.assert_called_once_with(TICKER, 1091.0, 'trail')
|
||||
|
||||
def test_trail_stop_not_fire_with_sell_uuid_set(self):
|
||||
"""sell_uuid가 있으면(지정가 대기 중) Trail Stop 발동 안 함."""
|
||||
tt.positions[TICKER] = _make_pos(
|
||||
entry_price=1000, seconds_ago=400, sell_uuid='uuid-1', stage=3
|
||||
)
|
||||
tt.positions[TICKER]['trail_peak_set'] = True
|
||||
tt.positions[TICKER]['running_peak'] = 1100.0
|
||||
|
||||
with patch.object(tt, 'do_sell_market') as mock_sell:
|
||||
tt.update_positions({TICKER: 900.0})
|
||||
mock_sell.assert_not_called()
|
||||
|
||||
def test_peak_initialized_to_current_price_at_300s(self):
|
||||
"""300s 첫 틱에서 running_peak이 진입가 아닌 현재가로 초기화된다."""
|
||||
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
|
||||
pos['trail_peak_set'] = False
|
||||
tt.positions[TICKER] = pos
|
||||
|
||||
with patch.object(tt, 'do_sell_market'):
|
||||
tt.update_positions({TICKER: 950.0}) # 진입가보다 낮은 현재가
|
||||
|
||||
assert tt.positions[TICKER]['running_peak'] == 950.0, \
|
||||
"running_peak이 현재가(950)로 초기화되어야 함"
|
||||
assert tt.positions[TICKER]['trail_peak_set'] is True
|
||||
|
||||
def test_trail_stop_does_not_fire_on_peak_init_tick(self):
|
||||
"""peak 초기화 첫 틱에서는 drop=0이므로 Trail Stop 발동 안 함."""
|
||||
pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4)
|
||||
pos['trail_peak_set'] = False
|
||||
tt.positions[TICKER] = pos
|
||||
|
||||
with patch.object(tt, 'do_sell_market') as mock_sell:
|
||||
tt.update_positions({TICKER: 850.0}) # 진입가 대비 -15%
|
||||
mock_sell.assert_not_called() # 초기화 틱이므로 발동 안 함
|
||||
|
||||
def test_submit_fail_sell_uuid_none_no_trail_before_300s(self):
|
||||
"""
|
||||
[회귀] HOLO 버그 재현: ① 지정가 제출 실패(sell_uuid=None) + 진입 2초 후
|
||||
→ Trail Stop이 발동하면 안 됨.
|
||||
"""
|
||||
tt.positions[TICKER] = _make_pos(
|
||||
entry_price=97, seconds_ago=2, sell_uuid=None, stage=0
|
||||
)
|
||||
with patch.object(tt, 'do_sell_market') as mock_sell:
|
||||
tt.update_positions({TICKER: 95.0}) # -2.06% 하락
|
||||
mock_sell.assert_not_called() # 300s 이전이므로 발동 안 함
|
||||
|
||||
|
||||
# ── _advance_stage ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAdvanceStage:
|
||||
|
||||
def setup_method(self):
|
||||
tt.positions.clear()
|
||||
|
||||
def test_advance_from_stage0_to_stage1(self):
|
||||
"""① → ② 단계 전환 시 sell_uuid 갱신."""
|
||||
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='old-uuid')
|
||||
with patch.object(tt, 'cancel_order_safe'), \
|
||||
patch.object(tt, 'submit_limit_sell', return_value='new-uuid'):
|
||||
tt._advance_stage(TICKER)
|
||||
|
||||
pos = tt.positions[TICKER]
|
||||
assert pos['stage'] == 1
|
||||
assert pos['sell_uuid'] == 'new-uuid'
|
||||
assert abs(pos['sell_price'] - 1000 * 1.01) < 0.01
|
||||
|
||||
def test_advance_to_trail_stage(self):
|
||||
"""마지막 cascade 단계 → ⑤ Trail 전환 시 sell_uuid=None."""
|
||||
tt.positions[TICKER] = _make_pos(stage=len(tt.CASCADE_STAGES) - 1)
|
||||
with patch.object(tt, 'cancel_order_safe'), \
|
||||
patch.object(tt, 'submit_limit_sell'):
|
||||
tt._advance_stage(TICKER)
|
||||
|
||||
pos = tt.positions[TICKER]
|
||||
assert pos['stage'] == len(tt.CASCADE_STAGES)
|
||||
assert pos['sell_uuid'] is None
|
||||
|
||||
def test_advance_submit_fail_sell_uuid_none(self):
|
||||
"""지정가 재주문 실패(submit_limit_sell=None) 시 sell_uuid=None — Trail 비활성 확인."""
|
||||
tt.positions[TICKER] = _make_pos(stage=0, seconds_ago=50)
|
||||
with patch.object(tt, 'cancel_order_safe'), \
|
||||
patch.object(tt, 'submit_limit_sell', return_value=None):
|
||||
tt._advance_stage(TICKER)
|
||||
|
||||
pos = tt.positions[TICKER]
|
||||
assert pos['sell_uuid'] is None
|
||||
# Trail Stop은 300s 미경과이므로 update_positions에서 발동 안 해야 함
|
||||
with patch.object(tt, 'do_sell_market') as mock_sell:
|
||||
tt.update_positions({TICKER: 500.0}) # -50% 하락이어도
|
||||
mock_sell.assert_not_called()
|
||||
|
||||
|
||||
# ── check_filled_positions ────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckFilledPositions:
|
||||
|
||||
def setup_method(self):
|
||||
tt.positions.clear()
|
||||
|
||||
def test_done_order_records_exit(self):
|
||||
"""체결 완료(done) 시 _record_exit 호출 (실거래 모드)."""
|
||||
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=10)
|
||||
with patch.object(tt, 'check_order_state', return_value=('done', 1020.0)), \
|
||||
patch.object(tt, '_record_exit') as mock_exit, \
|
||||
patch.object(tt, 'SIM_MODE', False):
|
||||
tt.check_filled_positions()
|
||||
mock_exit.assert_called_once_with(TICKER, 1020.0, '①')
|
||||
|
||||
def test_timeout_advances_stage(self):
|
||||
"""단계 시간 초과 시 _advance_stage 호출."""
|
||||
stage_end = tt.CASCADE_STAGES[0][1] # 40s
|
||||
tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1',
|
||||
seconds_ago=stage_end + 5)
|
||||
with patch.object(tt, 'check_order_state', return_value=('wait', None)), \
|
||||
patch.object(tt, '_advance_stage') as mock_advance:
|
||||
tt.check_filled_positions()
|
||||
mock_advance.assert_called_once_with(TICKER)
|
||||
|
||||
def test_cancelled_order_resubmits(self):
|
||||
"""주문 취소(cancel) 감지 시 _advance_stage 호출 (실거래 모드)."""
|
||||
tt.positions[TICKER] = _make_pos(stage=1, sell_uuid='u1', seconds_ago=50)
|
||||
with patch.object(tt, 'check_order_state', return_value=('cancel', None)), \
|
||||
patch.object(tt, '_advance_stage') as mock_advance, \
|
||||
patch.object(tt, 'SIM_MODE', False):
|
||||
tt.check_filled_positions()
|
||||
mock_advance.assert_called_once_with(TICKER)
|
||||
|
||||
def test_trail_stage_skipped(self):
|
||||
"""Trail 단계(sell_uuid=None)는 check_filled_positions에서 스킵."""
|
||||
tt.positions[TICKER] = _make_pos(stage=4, sell_uuid=None, seconds_ago=4000)
|
||||
with patch.object(tt, 'check_order_state') as mock_state:
|
||||
tt.check_filled_positions()
|
||||
mock_state.assert_not_called()
|
||||
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()
|
||||
696
core/llm_advisor.py
Normal file
696
core/llm_advisor.py
Normal file
@@ -0,0 +1,696 @@
|
||||
"""OpenRouter LLM 기반 매수 어드바이저.
|
||||
|
||||
시그널 감지 후 LLM이 매수 여부를 판단한다.
|
||||
매도는 트레일링 스탑으로 대체되어 LLM을 사용하지 않는다.
|
||||
|
||||
DB Tool (OpenAI function calling):
|
||||
- get_price_ticks: Oracle price_tick (최근 N분 가격 틱)
|
||||
- get_ohlcv: Oracle backtest_ohlcv 1분봉
|
||||
- get_ticker_context: 종목 평판 (가격 변동, 뉴스)
|
||||
- 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
|
||||
178
core/order.py
Normal file
178
core/order.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Upbit 주문 실행 모듈.
|
||||
|
||||
주문 제출, 취소, 체결 조회, 시장가 매도 등
|
||||
Upbit REST API와 직접 통신하는 로직을 담당한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pyupbit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def round_price(price: float) -> float:
|
||||
"""Upbit 호가 단위로 내림 처리.
|
||||
|
||||
Args:
|
||||
price: 원본 가격.
|
||||
|
||||
Returns:
|
||||
호가 단위에 맞춰 내림된 가격.
|
||||
"""
|
||||
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_buy(
|
||||
client: pyupbit.Upbit,
|
||||
ticker: str,
|
||||
price: float,
|
||||
qty: float,
|
||||
sim_mode: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""지정가 매수 주문 제출.
|
||||
|
||||
Returns:
|
||||
주문 UUID. 실패 시 None.
|
||||
"""
|
||||
price = round_price(price)
|
||||
if sim_mode:
|
||||
return f"sim-buy-{ticker}"
|
||||
try:
|
||||
order = client.buy_limit_order(ticker, price, qty)
|
||||
if not order or 'error' in str(order):
|
||||
log.error(f"지정가 매수 실패 {ticker}: {order}")
|
||||
return None
|
||||
return order.get('uuid')
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.error(f"지정가 매수 오류 {ticker}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def submit_limit_sell(
|
||||
client: pyupbit.Upbit,
|
||||
ticker: str,
|
||||
qty: float,
|
||||
price: float,
|
||||
sim_mode: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""지정가 매도 주문 제출.
|
||||
|
||||
Returns:
|
||||
주문 UUID. 실패 시 None.
|
||||
"""
|
||||
price = round_price(price)
|
||||
if sim_mode:
|
||||
return f"sim-{ticker}"
|
||||
try:
|
||||
order = 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 (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.error(f"지정가 매도 오류 {ticker}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def cancel_order(
|
||||
client: pyupbit.Upbit,
|
||||
uuid: Optional[str],
|
||||
sim_mode: bool = False,
|
||||
) -> None:
|
||||
"""주문 취소. sim_mode이거나 uuid가 없으면 무시."""
|
||||
if sim_mode or not uuid or uuid.startswith('sim-'):
|
||||
return
|
||||
try:
|
||||
client.cancel_order(uuid)
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.warning(f"주문 취소 실패 {uuid}: {e}")
|
||||
|
||||
|
||||
def check_order_state(
|
||||
client: pyupbit.Upbit,
|
||||
uuid: str,
|
||||
) -> Tuple[Optional[str], Optional[float]]:
|
||||
"""주문 상태 조회.
|
||||
|
||||
Returns:
|
||||
(state, avg_price) 튜플. state: 'done'|'wait'|'cancel'|None.
|
||||
"""
|
||||
try:
|
||||
detail = 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 (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.warning(f"주문 조회 실패 {uuid}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _avg_price_from_order(
|
||||
client: pyupbit.Upbit,
|
||||
uuid: str,
|
||||
) -> Optional[float]:
|
||||
"""체결 내역에서 가중평균 체결가를 계산."""
|
||||
try:
|
||||
detail = 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 (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.warning(f"체결가 조회 실패 {uuid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def sell_market(
|
||||
client: pyupbit.Upbit,
|
||||
ticker: str,
|
||||
qty: float,
|
||||
sim_mode: bool = False,
|
||||
) -> Optional[float]:
|
||||
"""시장가 매도. 체결가를 반환.
|
||||
|
||||
Args:
|
||||
client: Upbit 클라이언트.
|
||||
ticker: 종목 코드.
|
||||
qty: 매도 수량.
|
||||
sim_mode: 시뮬레이션 모드.
|
||||
|
||||
Returns:
|
||||
체결 평균가. 실패 시 None.
|
||||
"""
|
||||
if sim_mode:
|
||||
price = pyupbit.get_current_price(ticker)
|
||||
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
|
||||
return price
|
||||
try:
|
||||
order = 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(client, uuid) if uuid else None
|
||||
return avg_price or pyupbit.get_current_price(ticker)
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.error(f"시장가 매도 오류 {ticker}: {e}")
|
||||
return None
|
||||
264
core/position_manager.py
Normal file
264
core/position_manager.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""포지션 + 미체결 매수 관리 모듈.
|
||||
|
||||
포지션 활성화, 트레일링 스탑/손절/타임아웃 체크,
|
||||
미체결 매수 체결 확인, 예산 계산 등을 담당한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import oracledb
|
||||
import os
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── DB 연결 (position_sync) ──────────────────────────────────────────────────
|
||||
_db_conn: Optional[oracledb.Connection] = None
|
||||
|
||||
|
||||
def _get_db() -> oracledb.Connection:
|
||||
"""Oracle ADB 연결을 반환. 끊어졌으면 재연결."""
|
||||
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: Optional[float] = None,
|
||||
sell_price: Optional[float] = None,
|
||||
qty: Optional[float] = None,
|
||||
order_uuid: Optional[str] = None,
|
||||
invested_krw: Optional[int] = None,
|
||||
) -> None:
|
||||
"""position_sync 테이블에 포지션 상태를 기록/삭제.
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드.
|
||||
state: 'PENDING_BUY' | 'PENDING_SELL' | 'IDLE'.
|
||||
"""
|
||||
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 oracledb.Error as e:
|
||||
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
|
||||
global _db_conn
|
||||
_db_conn = None
|
||||
|
||||
|
||||
def calc_remaining_budget(
|
||||
positions: dict,
|
||||
pending_buys: dict,
|
||||
max_budget: int,
|
||||
) -> float:
|
||||
"""남은 투자 가능 금액을 계산.
|
||||
|
||||
Args:
|
||||
positions: 현재 포지션 dict.
|
||||
pending_buys: 미체결 매수 dict.
|
||||
max_budget: 총 예산.
|
||||
|
||||
Returns:
|
||||
남은 투자 가능 금액 (원).
|
||||
"""
|
||||
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
|
||||
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
|
||||
return max_budget - invested
|
||||
|
||||
|
||||
def check_exit_conditions(
|
||||
pos: dict,
|
||||
current_price: float,
|
||||
*,
|
||||
trail_pct: float = 0.015,
|
||||
min_profit_pct: float = 0.005,
|
||||
stop_loss_pct: float = 0.02,
|
||||
timeout_secs: float = 14400,
|
||||
) -> Optional[str]:
|
||||
"""포지션 청산 조건을 체크.
|
||||
|
||||
Args:
|
||||
pos: 포지션 dict (entry_price, entry_ts, running_peak).
|
||||
current_price: 현재 가격.
|
||||
|
||||
Returns:
|
||||
청산 사유 ('stoploss' | 'trail' | 'timeout') 또는 None.
|
||||
"""
|
||||
entry = pos['entry_price']
|
||||
profit_pct = (current_price - entry) / entry
|
||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||
|
||||
# 1. 손절
|
||||
if profit_pct <= -stop_loss_pct:
|
||||
return 'stoploss'
|
||||
|
||||
# 2. 트레일링 스탑
|
||||
peak = pos['running_peak']
|
||||
if peak > 0:
|
||||
drop = (peak - current_price) / peak
|
||||
if profit_pct >= min_profit_pct and drop >= trail_pct:
|
||||
return 'trail'
|
||||
|
||||
# 3. 타임아웃
|
||||
if elapsed >= timeout_secs:
|
||||
return 'timeout'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def restore_from_upbit(
|
||||
client,
|
||||
tickers: list[str],
|
||||
positions: dict,
|
||||
pending_buys: dict,
|
||||
*,
|
||||
cancel_fn,
|
||||
fp_fn,
|
||||
tg_fn,
|
||||
) -> None:
|
||||
"""Upbit 잔고에서 포지션과 미체결 매수를 복구.
|
||||
|
||||
Args:
|
||||
client: pyupbit.Upbit 인스턴스.
|
||||
tickers: 감시 종목 리스트.
|
||||
positions: 포지션 dict (in-place 수정).
|
||||
pending_buys: 미체결 매수 dict (in-place 수정).
|
||||
cancel_fn: 주문 취소 함수.
|
||||
fp_fn: 가격 포맷 함수.
|
||||
tg_fn: 텔레그램 알림 함수.
|
||||
"""
|
||||
_restore_positions(client, tickers, positions, cancel_fn, fp_fn, tg_fn)
|
||||
_restore_pending_buys(client, tickers, positions, pending_buys, fp_fn)
|
||||
_sync_restored(positions, pending_buys)
|
||||
|
||||
|
||||
def _restore_positions(
|
||||
client, tickers: list[str], positions: dict,
|
||||
cancel_fn, fp_fn, tg_fn,
|
||||
) -> None:
|
||||
"""잔고에서 보유 포지션을 복구."""
|
||||
balances = 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 or ticker in positions:
|
||||
if ticker not in tickers:
|
||||
log.info(f"[복구] {ticker} TICKERS 외 -> 스킵")
|
||||
continue
|
||||
|
||||
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp_fn(avg)}원")
|
||||
|
||||
# 기존 미체결 매도 주문 취소
|
||||
try:
|
||||
old_orders = client.get_order(ticker, state='wait') or []
|
||||
for o in (old_orders if isinstance(old_orders, list) else []):
|
||||
if o.get('side') == 'ask':
|
||||
cancel_fn(o.get('uuid'))
|
||||
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
|
||||
|
||||
time.sleep(0.5)
|
||||
actual_bal = 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_fn(avg)}원 트레일링")
|
||||
tg_fn(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp_fn(avg)}원 수량: {actual_bal:.6f}")
|
||||
|
||||
|
||||
def _restore_pending_buys(
|
||||
client, tickers: list[str], positions: dict,
|
||||
pending_buys: dict, fp_fn,
|
||||
) -> None:
|
||||
"""미체결 매수 주문을 복구."""
|
||||
for ticker in tickers:
|
||||
if ticker in positions or ticker in pending_buys:
|
||||
continue
|
||||
try:
|
||||
orders = 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_fn(price)}원 수량:{rem:.6f}")
|
||||
break
|
||||
except (ConnectionError, TimeoutError, ValueError):
|
||||
log.warning(f"[복구] {ticker} 미체결 매수 조회 실패")
|
||||
|
||||
|
||||
def _sync_restored(positions: dict, pending_buys: dict) -> None:
|
||||
"""복구된 포지션을 position_sync DB에 반영."""
|
||||
restored = len(positions) + len(pending_buys)
|
||||
if restored:
|
||||
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
|
||||
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']),
|
||||
)
|
||||
139
core/signal.py
Normal file
139
core/signal.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""시그널 감지 + 지표 계산 모듈.
|
||||
|
||||
20초봉 데이터에서 양봉 + 거래량 + 사전 필터를 적용하여
|
||||
매수 시그널 후보를 반환한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calc_vr(bar_list: list[dict], idx: int, lookback: int = 61) -> float:
|
||||
"""거래량비(Volume Ratio) 계산. 상위 10% 트리밍.
|
||||
|
||||
Args:
|
||||
bar_list: 봉 리스트.
|
||||
idx: 현재 봉 인덱스.
|
||||
lookback: 기준 봉 수.
|
||||
|
||||
Returns:
|
||||
현재 봉 거래량 / trimmed mean 비율.
|
||||
"""
|
||||
start = max(0, idx - lookback)
|
||||
end = max(0, idx - 2)
|
||||
baseline = sorted(bar_list[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 bar_list[idx]['volume'] / avg if avg > 0 else 0.0
|
||||
|
||||
|
||||
def calc_atr(bar_list: list[dict], lookback: int = 28) -> float:
|
||||
"""ATR(Average True Range) 비율 계산.
|
||||
|
||||
Args:
|
||||
bar_list: 봉 리스트.
|
||||
lookback: ATR 계산 봉 수.
|
||||
|
||||
Returns:
|
||||
ATR / 직전 종가 비율 (0~1 범위).
|
||||
"""
|
||||
if len(bar_list) < lookback + 2:
|
||||
return 0.0
|
||||
trs = []
|
||||
for i in range(-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
|
||||
|
||||
|
||||
def detect_signal(
|
||||
ticker: str,
|
||||
bar_list: list[dict],
|
||||
*,
|
||||
vol_min: float = 5.0,
|
||||
vol_lookback: int = 61,
|
||||
vol_krw_min: float = 5_000_000,
|
||||
spread_min: float = 0.3,
|
||||
) -> Optional[dict]:
|
||||
"""양봉 + 거래량 + 사전 필터 3종을 적용하여 시그널 후보를 반환.
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드.
|
||||
bar_list: 봉 리스트 (list로 변환된 deque).
|
||||
vol_min: 최소 거래량 배수.
|
||||
vol_lookback: 거래량 평균 기준 봉 수.
|
||||
vol_krw_min: 최소 거래대금 (원).
|
||||
spread_min: 횡보 필터 최소 변동폭 (%).
|
||||
|
||||
Returns:
|
||||
시그널 dict 또는 None.
|
||||
"""
|
||||
n = len(bar_list)
|
||||
if n < vol_lookback + 5:
|
||||
return None
|
||||
|
||||
b = bar_list[-1]
|
||||
if b['close'] <= b['open']:
|
||||
return None
|
||||
|
||||
vr = calc_vr(bar_list, n - 1, lookback=vol_lookback)
|
||||
if vr < vol_min:
|
||||
return None
|
||||
|
||||
bar_krw = b['close'] * b['volume']
|
||||
if bar_krw < vol_krw_min:
|
||||
return None
|
||||
|
||||
# 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 < spread_min:
|
||||
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% -> 스킵")
|
||||
return None
|
||||
|
||||
# 2) 고점 필터: 30분 구간 90%+ 위치 & 변동 1%+
|
||||
long_bars = bar_list[-90:]
|
||||
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)
|
||||
move_pct = (long_high - long_low) / long_low * 100
|
||||
if pos_in_range > 0.9 and move_pct > 1.0:
|
||||
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {move_pct:.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,
|
||||
}
|
||||
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()
|
||||
184
daemons/state_sync.py
Normal file
184
daemons/state_sync.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""10초 주기로 Upbit 잔고/미체결 주문을 조회하여 position_sync 테이블 동기화.
|
||||
|
||||
상태:
|
||||
PENDING_BUY — 매수 주문 제출됨 (미체결)
|
||||
HOLDING — 보유 중 (매도 주문 없음)
|
||||
PENDING_SELL — 매도 주문 제출됨 (미체결)
|
||||
IDLE — 아무 것도 없음 (행 삭제)
|
||||
|
||||
tick_trader는 이 테이블을 읽어서 positions/pending_buys를 복구한다.
|
||||
|
||||
실행:
|
||||
.venv/bin/python3 daemons/state_sync.py
|
||||
로그:
|
||||
/tmp/state_sync.log
|
||||
"""
|
||||
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 = 10 # 초
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/tmp/state_sync.log'),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
]
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
|
||||
|
||||
|
||||
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 sync_once(conn):
|
||||
"""Upbit 실제 상태를 조회하여 position_sync 테이블 갱신."""
|
||||
cur = conn.cursor()
|
||||
now = datetime.now()
|
||||
|
||||
# 1. 잔고 조회 → 보유 종목 파악
|
||||
balances = upbit.get_balances() or []
|
||||
held = {} # ticker → {qty, avg_price}
|
||||
for b in balances:
|
||||
currency = b.get('currency', '')
|
||||
if currency == 'KRW':
|
||||
continue
|
||||
ticker = f'KRW-{currency}'
|
||||
if ticker not in TICKERS:
|
||||
continue
|
||||
bal = float(b.get('balance', 0))
|
||||
locked = float(b.get('locked', 0))
|
||||
total = bal + locked
|
||||
avg = float(b.get('avg_buy_price', 0))
|
||||
if total > 0 and avg > 0:
|
||||
held[ticker] = {'qty': total, 'avg_price': avg, 'invested': int(total * avg)}
|
||||
|
||||
# 2. 미체결 주문 조회 → 매수/매도 대기 파악
|
||||
pending_buys = {} # ticker → {uuid, price, qty}
|
||||
pending_sells = {} # ticker → {uuid, price, qty}
|
||||
for ticker in TICKERS:
|
||||
try:
|
||||
orders = upbit.get_order(ticker, state='wait') or []
|
||||
if not isinstance(orders, list):
|
||||
continue
|
||||
for o in orders:
|
||||
side = o.get('side')
|
||||
uuid = o.get('uuid')
|
||||
price = float(o.get('price', 0))
|
||||
rem = float(o.get('remaining_volume', 0))
|
||||
if price <= 0 or rem <= 0:
|
||||
continue
|
||||
if side == 'bid':
|
||||
pending_buys[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
|
||||
elif side == 'ask':
|
||||
pending_sells[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 상태 결정 및 DB 반영
|
||||
active_tickers = set(held.keys()) | set(pending_buys.keys()) | set(pending_sells.keys())
|
||||
|
||||
for ticker in active_tickers:
|
||||
if ticker in pending_buys and ticker not in held:
|
||||
state = 'PENDING_BUY'
|
||||
pb = pending_buys[ticker]
|
||||
buy_price = pb['price']
|
||||
sell_price = None
|
||||
qty = pb['qty']
|
||||
order_uuid = pb['uuid']
|
||||
invested = int(qty * buy_price)
|
||||
elif ticker in held and ticker in pending_sells:
|
||||
state = 'PENDING_SELL'
|
||||
h = held[ticker]
|
||||
ps = pending_sells[ticker]
|
||||
buy_price = h['avg_price']
|
||||
sell_price = ps['price']
|
||||
qty = h['qty']
|
||||
order_uuid = ps['uuid']
|
||||
invested = h['invested']
|
||||
elif ticker in held:
|
||||
state = 'HOLDING'
|
||||
h = held[ticker]
|
||||
buy_price = h['avg_price']
|
||||
sell_price = None
|
||||
qty = h['qty']
|
||||
order_uuid = None
|
||||
invested = h['invested']
|
||||
else:
|
||||
continue
|
||||
|
||||
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, now,
|
||||
ticker, state, buy_price, sell_price, qty, order_uuid, invested, now]
|
||||
)
|
||||
|
||||
# 4. 이제 없는 종목은 삭제
|
||||
if active_tickers:
|
||||
placeholders = ','.join(f"'{t}'" for t in active_tickers)
|
||||
cur.execute(f"DELETE FROM position_sync WHERE ticker NOT IN ({placeholders})")
|
||||
else:
|
||||
cur.execute("DELETE FROM position_sync")
|
||||
|
||||
conn.commit()
|
||||
|
||||
if active_tickers:
|
||||
summary = ', '.join(f"{t.split('-')[1]}={cur.execute('SELECT state FROM position_sync WHERE ticker=:1',[t]).fetchone()[0]}" for t in sorted(active_tickers))
|
||||
log.info(f"[동기화] {summary}")
|
||||
|
||||
|
||||
def main():
|
||||
log.info(f"=== state_sync 시작 (주기 {INTERVAL}초) ===")
|
||||
conn = get_conn()
|
||||
fail_count = 0
|
||||
while True:
|
||||
try:
|
||||
sync_once(conn)
|
||||
fail_count = 0
|
||||
except Exception as e:
|
||||
fail_count += 1
|
||||
log.error(f"[동기화 오류] {e}", exc_info=(fail_count <= 3))
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn = get_conn()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
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()
|
||||
489
daemons/tick_trader.py
Normal file
489
daemons/tick_trader.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""WebSocket 기반 20초봉 트레이더 (Controller).
|
||||
|
||||
구조:
|
||||
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
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime
|
||||
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_entry_price
|
||||
from core.signal import detect_signal, calc_vr
|
||||
from core.order import (
|
||||
round_price, submit_limit_buy, cancel_order,
|
||||
check_order_state, sell_market,
|
||||
)
|
||||
from core.position_manager import (
|
||||
sync_position, calc_remaining_budget,
|
||||
check_exit_conditions, restore_from_upbit,
|
||||
)
|
||||
|
||||
import pyupbit
|
||||
|
||||
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
||||
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
|
||||
VOL_MIN = 5.0
|
||||
VOL_KRW_MIN = 5_000_000
|
||||
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
|
||||
MIN_PROFIT_PCT = 0.005
|
||||
STOP_LOSS_PCT = 0.02
|
||||
TIMEOUT_SECS = 14400
|
||||
|
||||
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__)
|
||||
|
||||
# ── 상태 ──────────────────────────────────────────────────────────────────────
|
||||
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
||||
cur_bar: dict = {}
|
||||
bar_lock = threading.Lock()
|
||||
positions: dict = {}
|
||||
pending_buys: dict = {}
|
||||
|
||||
|
||||
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
|
||||
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 (ConnectionError, TimeoutError) as e:
|
||||
log.warning(f'Telegram 전송 실패: {e}')
|
||||
|
||||
|
||||
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
||||
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:
|
||||
"""WebSocket tick -> 현재 봉에 반영."""
|
||||
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마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
|
||||
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)
|
||||
if ticker in positions or ticker in pending_buys:
|
||||
continue
|
||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||
continue
|
||||
sig = detect_signal(
|
||||
ticker, list(bars[ticker]),
|
||||
vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK,
|
||||
vol_krw_min=VOL_KRW_MIN,
|
||||
)
|
||||
if sig:
|
||||
signals.append(sig)
|
||||
for sig in signals:
|
||||
process_signal(sig)
|
||||
check_pending_buys()
|
||||
check_filled_positions()
|
||||
|
||||
|
||||
# ── 매수 처리 ─────────────────────────────────────────────────────────────────
|
||||
def process_signal(sig: dict) -> None:
|
||||
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
|
||||
ticker = sig['ticker']
|
||||
cur_price = sig['price']
|
||||
vol_ratio = sig['vol_ratio']
|
||||
|
||||
if ticker in positions or ticker in pending_buys:
|
||||
return
|
||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||
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=sig['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':
|
||||
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
|
||||
return
|
||||
|
||||
if ticker in positions or ticker in pending_buys:
|
||||
return
|
||||
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
|
||||
return
|
||||
|
||||
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
|
||||
|
||||
|
||||
def _handle_skip(
|
||||
ticker: str, price: float, vol_ratio: float,
|
||||
llm_result: Optional[dict],
|
||||
) -> None:
|
||||
"""LLM skip 결과 로깅 + 텔레그램 알림."""
|
||||
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(price)}원 볼륨: {vol_ratio:.1f}x\n"
|
||||
f"시장: {status}\n"
|
||||
f"사유: {reason}"
|
||||
)
|
||||
|
||||
|
||||
def _submit_buy(
|
||||
ticker: str, cur_price: float, vol_ratio: float,
|
||||
llm_result: dict,
|
||||
) -> None:
|
||||
"""LLM 승인 후 예산 체크 -> 지정가 매수 제출."""
|
||||
buy_price = round_price(cur_price)
|
||||
confidence = llm_result.get('confidence', '?')
|
||||
reason = llm_result.get('reason', '')
|
||||
status = llm_result.get('market_status', '')
|
||||
|
||||
remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET)
|
||||
invest_amt = min(PER_POS, remaining)
|
||||
if invest_amt < 5000:
|
||||
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵")
|
||||
return
|
||||
|
||||
qty = invest_amt * (1 - FEE) / buy_price
|
||||
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)")
|
||||
|
||||
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
|
||||
if uuid is None:
|
||||
return
|
||||
|
||||
pending_buys[ticker] = {
|
||||
'uuid': uuid, 'price': buy_price, 'qty': qty,
|
||||
'ts': datetime.now(), 'vol_ratio': vol_ratio,
|
||||
}
|
||||
invested = int(qty * buy_price)
|
||||
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
|
||||
order_uuid=uuid, invested_krw=invested)
|
||||
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
|
||||
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(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
||||
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(upbit_client, 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(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
||||
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 '[실거래]'}"
|
||||
)
|
||||
|
||||
|
||||
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
||||
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 "🔴"
|
||||
invested = int(pos['qty'] * pos['entry_price'])
|
||||
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
||||
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 _try_exit(ticker: str, price: float) -> None:
|
||||
"""청산 조건 체크 후 시장가 매도 실행."""
|
||||
pos = positions[ticker]
|
||||
pos['running_peak'] = max(pos['running_peak'], price)
|
||||
|
||||
tag = check_exit_conditions(
|
||||
pos, price,
|
||||
trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT,
|
||||
stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS,
|
||||
)
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price
|
||||
if tag == 'trail':
|
||||
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
|
||||
drop = (pos['running_peak'] - price) / pos['running_peak'] * 100
|
||||
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%")
|
||||
elif tag == 'stoploss':
|
||||
profit = (price - pos['entry_price']) / pos['entry_price'] * 100
|
||||
log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)")
|
||||
elif tag == 'timeout':
|
||||
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
||||
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
|
||||
|
||||
_record_exit(ticker, exit_price, tag)
|
||||
|
||||
|
||||
def check_filled_positions() -> None:
|
||||
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
|
||||
for ticker in list(positions.keys()):
|
||||
if ticker not in positions:
|
||||
continue
|
||||
bar_list = list(bars.get(ticker, []))
|
||||
if not bar_list:
|
||||
continue
|
||||
_try_exit(ticker, bar_list[-1]['close'])
|
||||
|
||||
|
||||
def update_positions(current_prices: dict) -> None:
|
||||
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
|
||||
for ticker in list(positions.keys()):
|
||||
if ticker not in current_prices:
|
||||
continue
|
||||
_try_exit(ticker, current_prices[ticker])
|
||||
|
||||
|
||||
# ── 초기화 ────────────────────────────────────────────────────────────────────
|
||||
def preload_bars() -> None:
|
||||
"""REST API 1분봉으로 bars[] 사전 적재."""
|
||||
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 (ConnectionError, TimeoutError, ValueError) 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 잔고에서 포지션 + 미체결 매수 복구."""
|
||||
if SIM_MODE:
|
||||
return
|
||||
try:
|
||||
restore_from_upbit(
|
||||
upbit_client, TICKERS, positions, pending_buys,
|
||||
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
|
||||
fp_fn=fp, tg_fn=tg,
|
||||
)
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
||||
|
||||
|
||||
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
"""tick_trader 메인 루프."""
|
||||
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,49 @@ module.exports = {
|
||||
autorestart: true,
|
||||
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: "state-sync",
|
||||
script: "daemons/state_sync.py",
|
||||
interpreter: ".venv/bin/python3",
|
||||
cwd: "/Users/joungmin/workspaces/upbit-trader",
|
||||
out_file: "logs/state-sync.log",
|
||||
error_file: "logs/state-sync-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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,4 +5,6 @@ requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"pyupbit>=0.3.0",
|
||||
"python-dotenv>=1.0",
|
||||
"anthropic>=0.40",
|
||||
"oracledb>=2.0",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user