diff --git a/STRATEGY.md b/STRATEGY.md
index 061ec8b..1230457 100644
--- a/STRATEGY.md
+++ b/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중 체크 |
diff --git a/backtest_march.py b/backtest_march.py
new file mode 100644
index 0000000..81e20f3
--- /dev/null
+++ b/backtest_march.py
@@ -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()
diff --git a/core/llm_advisor.py b/core/llm_advisor.py
index 39581dc..d3a6cac 100644
--- a/core/llm_advisor.py
+++ b/core/llm_advisor.py
@@ -103,12 +103,15 @@ def _tool_get_context(ticker: str) -> str:
{'t': ticker},
)
rows = cur.fetchall()
- conn.close()
if not rows:
+ conn.close()
return f"{ticker} 컨텍스트 데이터 없음"
parts = []
for ctx_type, content in rows:
- parts.append(f"[{ctx_type}]\n{content}")
+ # 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}"
@@ -142,7 +145,8 @@ def _tool_get_trade_history(ticker: str, limit: int = 10) -> str:
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}%):"
- return header + "\n" + "\n".join(lines)
+ note = "\n ※ 과거 성과는 참고용입니다. 현재 시그널 강도가 높으면 과거 연패와 무관하게 진입하세요."
+ return header + "\n" + "\n".join(lines) + note
except Exception as e:
return f"DB 오류: {e}"
@@ -371,6 +375,41 @@ def _describe_bars(bar_list: list[dict], current_price: float) -> str:
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,
@@ -379,11 +418,13 @@ def _build_prompt(
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 ''
+ market_section = f'\n{market_context}\n' if market_context else ''
+ momentum_section = f'\n{momentum_desc}\n' if momentum_desc else ''
return f"""당신은 암호화폐 단기 트레이더입니다.
아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요.
@@ -399,11 +440,14 @@ def _build_prompt(
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
-
+{momentum_section}
[운용 정책 참고 — 최종 판단은 당신이 결정]
- 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정)
- 수익 목표: 진입가 대비 +0.5% ~ +2% 구간
+- 손절 기준: 진입가 대비 -2% 이하이면 즉시 시장가 매도를 강력 권고 (action=sell, price=현재가)
- 체결 가능성이 낮으면 현실적인 목표가로 조정 권장
+- **모멘텀이 강하면(1분~10분 전 대비 계속 상승 중) 성급하게 팔지 말고 hold하세요**
+- **양봉 비율이 높고 구간 저점 대비 상승폭이 크면 추세가 살아있는 것입니다**
- 상승 여력이 있으면 hold 권장
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
@@ -543,12 +587,11 @@ def get_entry_price(
market_section = f'\n{mkt_ctx}\n' if mkt_ctx else ''
prompt = f"""당신은 암호화폐 단기 트레이더입니다.
-아래 시그널을 분석해 **매수 여부와 지정가 매수 가격**을 판단하세요.
+아래 시그널을 분석해 **매수 여부**를 판단하세요. (매수 가격은 현재가로 자동 설정됩니다)
반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요:
-- get_btc_trend: BTC 추세 확인 (필수 — BTC 하락 시 알트 매수 위험)
+- get_btc_trend: BTC 추세 확인 (필수 — BTC 급락 시 알트 매수 위험)
- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인
-- get_trade_history: 이 종목 과거 거래 성과 확인
-- get_ohlcv: 1분봉으로 지지/저항선 확인
+- get_ohlcv: 1분봉으로 현재 추세 확인
[시그널 감지]
종목 : {ticker}
@@ -561,16 +604,16 @@ F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕
{bar_desc}
[판단 기준]
-- 거래량 급증이 진짜 매집 신호인지, 일시적 노이즈인지 구분
-- BTC가 하락 중이면 알트코인 매수 자제
-- 최근 이 종목에서 연패 중이면 신중하게
-- 현재가보다 약간 낮은 지정가를 설정해 유리한 가격에 매수
-- 상승 추세가 이미 많이 진행됐으면 진입 자제
+- 거래량 급증 시그널이 왔으면 빠르게 올라타는 것이 핵심
+- BTC가 급락 중(-2% 이상)이면 자제하되, 횡보/소폭하락은 진입 OK
+- 상승 추세가 이미 많이 진행됐으면(+5% 이상) 진입 자제
+- **과거 연패/승률은 절대 고려하지 마세요. 현재 시그널만 보고 판단하세요.**
+- **get_trade_history를 호출하지 마세요. 과거 거래 이력은 판단에 불필요합니다.**
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매수할 경우:
-{{"action": "buy", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
+{{"action": "buy", "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
매수하지 않을 경우:
{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}"""
@@ -621,12 +664,14 @@ def get_exit_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)
+ 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)
diff --git a/daemons/tick_trader.py b/daemons/tick_trader.py
index b8f3173..e125cd4 100644
--- a/daemons/tick_trader.py
+++ b/daemons/tick_trader.py
@@ -1,16 +1,9 @@
"""WebSocket 기반 20초봉 트레이더.
구조:
- WebSocket → trade tick 수신 → 20초봉 집계
- → 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수
- → LLM primary 매도 + cascade fallback 청산
-
-cascade (초 기준):
- ① 0~ 40초: +2.0% 지정가
- ② 40~ 100초: +1.0% 지정가
- ③ 100~ 300초: +0.5% 지정가
- ④ 300~3500초: +0.1% 지정가
- ⑤ 3500초~: Trail Stop 0.8% 시장가
+ WebSocket -> trade tick 수신 -> 20초봉 집계
+ -> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
+ -> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
실행:
.venv/bin/python3 daemons/tick_trader.py
@@ -29,38 +22,31 @@ load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file_
from core.llm_advisor import get_exit_price, get_entry_price
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',
+ 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
+ 'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
BAR_SEC = 20 # 봉 주기 (초)
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
ATR_LOOKBACK = 28 # ATR 계산 봉 수
-VOL_MIN = 6.0 # 거래량 배수 임계값
-BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초)
+VOL_MIN = 5.0 # 거래량 배수 임계값
+VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터
+BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초)
-MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
-PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
+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
-# cascade 청산 (초 기준) — 지정가 매도
-CASCADE_STAGES = [
- (0, 40, 0.020, '①'), # 2봉
- (40, 100, 0.010, '②'), # 3봉
- (100, 300, 0.005, '③'), # 10봉
- (300, 3500, 0.001, '④'), # 160봉
-]
-TRAIL_STOP_R = 0.008
-TIMEOUT_SECS = 14400 # 4시간
-LLM_INTERVAL = 60 # LLM 호출 간격 (초)
-LLM_MIN_ELAPSED = 60 # 진입 후 최소 N초 이후부터 LLM 활성
+# 트레일링 스탑 청산
+TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도
+MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5%
+STOP_LOSS_PCT = 0.02 # -2% 손절
+TIMEOUT_SECS = 14400 # 4시간
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
@@ -80,6 +66,49 @@ logging.basicConfig(
log = logging.getLogger(__name__)
+# ── position_sync DB ─────────────────────────────────────────────────────────
+_db_conn = None
+
+def _get_db():
+ global _db_conn
+ if _db_conn is None:
+ kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
+ dsn=os.environ["ORACLE_DSN"])
+ if w := os.environ.get("ORACLE_WALLET"):
+ kwargs["config_dir"] = w
+ _db_conn = oracledb.connect(**kwargs)
+ return _db_conn
+
+
+def sync_position(ticker: str, state: str, buy_price=None, sell_price=None,
+ qty=None, order_uuid=None, invested_krw=None):
+ """position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신."""
+ try:
+ conn = _get_db()
+ cur = conn.cursor()
+ if state == 'IDLE':
+ cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker])
+ else:
+ now = datetime.now()
+ cur.execute(
+ """MERGE INTO position_sync ps
+ USING (SELECT :1 AS ticker FROM dual) src
+ ON (ps.ticker = src.ticker)
+ WHEN MATCHED THEN UPDATE SET
+ state = :2, buy_price = :3, sell_price = :4,
+ qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
+ WHEN NOT MATCHED THEN INSERT
+ (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
+ VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
+ [ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now,
+ ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now])
+ conn.commit()
+ except Exception as e:
+ log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
+ global _db_conn
+ _db_conn = None
+
+
def fp(price: float) -> str:
"""가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시."""
if price >= 100:
@@ -153,10 +182,15 @@ def finalize_bars() -> None:
def calc_vr(bar_list: list, idx: int) -> float:
start = max(0, idx - VOL_LOOKBACK)
end = max(0, idx - 2)
- baseline = [bar_list[i]['volume'] for i in range(start, end)]
+ baseline = sorted(bar_list[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
- avg = sum(baseline) / len(baseline)
+ # 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지
+ trim = max(1, len(baseline) // 10)
+ trimmed = baseline[:len(baseline) - trim]
+ if not trimmed:
+ return 0.0
+ avg = sum(trimmed) / len(trimmed)
return bar_list[idx]['volume'] / avg if avg > 0 else 0.0
@@ -196,6 +230,44 @@ def detect_signal(ticker: str) -> Optional[dict]:
if vr < VOL_MIN:
return None
+ # 20초봉 거래대금 하드캡: 소량 조작 방지
+ bar_krw = b['close'] * b['volume']
+ if bar_krw < VOL_KRW_MIN:
+ return None
+
+ # ── LLM 호출 절감: skip 패턴 사전 필터 ──
+
+ # 1) 횡보 (최근 15봉 변동폭 < 0.3%) → 매수 매력 없음
+ recent = bar_list[-15:]
+ period_high = max(x['high'] for x in recent)
+ period_low = min(x['low'] for x in recent)
+ if period_low > 0:
+ spread_pct = (period_high - period_low) / period_low * 100
+ if spread_pct < 0.3:
+ log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵")
+ return None
+
+ # 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달)
+ long_bars = bar_list[-90:] # ~30분
+ long_high = max(x['high'] for x in long_bars)
+ long_low = min(x['low'] for x in long_bars)
+ if long_high > long_low:
+ pos_in_range = (b['close'] - long_low) / (long_high - long_low)
+ if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0:
+ log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.1f}% → 스킵")
+ return None
+
+ # 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉이어야 진입
+ prev_greens = 0
+ for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1):
+ if bar_list[k]['close'] > bar_list[k]['open']:
+ prev_greens += 1
+ else:
+ break
+ if prev_greens < 2:
+ log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 → 스킵")
+ return None
+
return {
'ticker': ticker,
'price': b['close'],
@@ -209,8 +281,7 @@ def _round_price(price: float) -> float:
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
if price >= 2_000_000: unit = 1000
elif price >= 1_000_000: unit = 500
- elif price >= 500_000: unit = 100
- elif price >= 100_000: unit = 50
+ elif price >= 100_000: unit = 100
elif price >= 10_000: unit = 10
elif price >= 1_000: unit = 5
elif price >= 100: unit = 1
@@ -222,12 +293,13 @@ def _round_price(price: float) -> float:
def submit_limit_sell(ticker: str, qty: float, price: float) -> Optional[str]:
"""지정가 매도 주문. Returns UUID."""
price = _round_price(price)
+ log.debug(f"[매도주문] {ticker} price={price} qty={qty}")
if SIM_MODE:
return f"sim-{ticker}"
try:
order = upbit_client.sell_limit_order(ticker, price, qty)
if not order or 'error' in str(order):
- log.error(f"지정가 매도 제출 실패: {order}")
+ log.error(f"지정가 매도 제출 실패 {ticker}: price={price} qty={qty} → {order}")
return None
return order.get('uuid')
except Exception as e:
@@ -306,6 +378,10 @@ def process_signal(sig: dict) -> None:
cur_price = sig['price']
vol_ratio = sig['vol_ratio']
+ # 이미 보유/매수대기 중인 종목 중복 방지
+ if ticker in positions or ticker in pending_buys:
+ return
+
# LLM 호출 전 포지션 수 재확인 (동시 진행 방지)
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵")
@@ -334,18 +410,29 @@ def process_signal(sig: dict) -> None:
)
return
- # LLM 호출 후 포지션 수 재확인
+ # LLM 호출 후 포지션 수/중복 재확인
+ if ticker in positions or ticker in pending_buys:
+ return
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
return
- buy_price = _round_price(llm_result['price'])
+ buy_price = _round_price(cur_price) # 현재가로 즉시 매수
confidence = llm_result.get('confidence', '?')
reason = llm_result.get('reason', '')
status = llm_result.get('market_status', '')
- qty = PER_POS * (1 - FEE) / buy_price
- diff_pct = (buy_price - cur_price) / cur_price * 100
- log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 {fp(cur_price)}원, 차이 {diff_pct:+.2f}%)")
+
+ # 예산 체크: MAX_BUDGET - 현재 투자금 합계
+ invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
+ invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
+ remaining = MAX_BUDGET - invested
+ invest_amt = min(PER_POS, remaining)
+ if invest_amt < 5000:
+ log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵")
+ return
+
+ qty = invest_amt * (1 - FEE) / buy_price
+ log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)")
if SIM_MODE:
uuid = f"sim-buy-{ticker}"
@@ -367,10 +454,13 @@ def process_signal(sig: dict) -> None:
'ts': datetime.now(),
'vol_ratio': vol_ratio,
}
+ sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
+ order_uuid=uuid, invested_krw=int(qty * buy_price))
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
+ invested = int(qty * buy_price)
tg(
f"📥 지정가 매수 {ticker}\n"
- f"지정가: {fp(buy_price)}원 (현재가 대비 {diff_pct:+.2f}%)\n"
+ f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
f"확신: {confidence} 시장: {status}\n"
f"LLM: {reason}\n"
@@ -388,6 +478,7 @@ def check_pending_buys() -> None:
if len(positions) >= MAX_POS:
cancel_order_safe(pb['uuid'])
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소")
+ sync_position(ticker, 'IDLE')
del pending_buys[ticker]
continue
@@ -412,31 +503,26 @@ def check_pending_buys() -> None:
cancel_order_safe(pb['uuid'])
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소")
tg(f"❌ 매수 취소 {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:
- """매수 체결 후 포지션 등록 + cascade 매도 설정."""
- _, _, lr, tag = CASCADE_STAGES[0]
- target = entry_price * (1 + lr)
- sell_uuid = submit_limit_sell(ticker, qty, target)
-
+ """매수 체결 후 포지션 등록 (트레일링 스탑)."""
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
- 'stage': 0,
- 'sell_uuid': sell_uuid,
- 'sell_price': target,
- 'llm_last_ts': None,
- 'llm_active': False,
}
- log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 지정가 {tag} {fp(target)}원")
+ 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"🟢 매수 체결 {ticker}\n"
- f"체결가: {fp(entry_price)}원 수량: {qty:.6f}\n"
- f"지정가 매도: {tag} {fp(target)}원 (+{lr*100:.1f}%)\n"
+ f"체결가: {fp(entry_price)}원 투자: {invested:,}원\n"
+ f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
@@ -445,25 +531,6 @@ def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: f
positions: dict = {}
-def _advance_stage(ticker: str) -> None:
- """다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문."""
- pos = positions[ticker]
- cancel_order_safe(pos.get('sell_uuid'))
- next_stage = pos['stage'] + 1
- pos['stage'] = next_stage
-
- if next_stage < len(CASCADE_STAGES):
- _, _, lr, tag = CASCADE_STAGES[next_stage]
- target = pos['entry_price'] * (1 + lr)
- uuid = submit_limit_sell(ticker, pos['qty'], target)
- pos['sell_uuid'] = uuid
- pos['sell_price'] = target
- log.info(f"[단계전환] {ticker} → {tag} 목표가 {fp(target)}원")
- else:
- pos['sell_uuid'] = None
- pos['sell_price'] = None
- log.info(f"[단계전환] {ticker} → ⑤ Trail Stop")
-
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
"""체결 완료 후 포지션 종료 처리."""
@@ -473,145 +540,90 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
held = int((datetime.now() - pos['entry_ts']).total_seconds())
reason_tag = {
- '①': '① +2.0% 익절', '②': '② +1.0% 익절',
- '③': '③ +0.5% 익절', '④': '④ +0.1% 본전',
- 'trail': '⑤ 트레일스탑', 'timeout': '⑤ 타임아웃',
+ 'trail': '트레일스탑', 'timeout': '타임아웃',
+ 'stoploss': '손절', 'llm': 'LLM 매도',
}.get(tag, tag)
- llm_flag = 'LLM' if pos.get('llm_active') else 'cascade'
icon = "✅" if pnl > 0 else "🔴"
- log.info(f"[청산/{tag}/{llm_flag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
+ log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
+ invested = int(pos['qty'] * pos['entry_price'])
tg(
- f"{icon} 청산 {ticker} [{reason_tag}] ({llm_flag})\n"
- f"진입: {fp(pos['entry_price'])}원\n"
- f"청산: {fp(exit_price)}원\n"
+ f"{icon} 청산 {ticker} [{reason_tag}]\n"
+ f"투자: {invested:,}원\n"
+ f"진입: {fp(pos['entry_price'])}원 → 청산: {fp(exit_price)}원\n"
f"PNL: {pnl:+.2f}% ({krw:+,.0f}원) {held}초 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
+ sync_position(ticker, 'IDLE')
del positions[ticker]
-def _should_call_llm(pos: dict, elapsed: float) -> bool:
- """LLM 호출 조건: 진입 후 LLM_MIN_ELAPSED 초 경과 + LLM_INTERVAL 간격."""
- if elapsed < LLM_MIN_ELAPSED:
- return False
- last = pos.get('llm_last_ts')
- if last is None:
- return True
- return (datetime.now() - last).total_seconds() >= LLM_INTERVAL
-
-
def check_filled_positions() -> None:
- """20초마다 지정가 체결 확인.
-
- 흐름:
- 1. 체결 완료 확인
- 2. LLM 어드바이저 호출 (1분 주기) → 목표가 반환 시 주문 교체
- 3. LLM hold/오류 시 cascade fallback (단계 시간 초과 → 다음 단계)
- """
+ """20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃."""
for ticker in list(positions.keys()):
if ticker not in positions:
continue
pos = positions[ticker]
- uuid = pos.get('sell_uuid')
- elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
-
- if uuid is None:
- # Trail Stop 구간 — update_positions(tick)에서 처리
+ bar_list = list(bars.get(ticker, []))
+ if not bar_list:
continue
- stage = pos['stage']
- _, end, _, tag = CASCADE_STAGES[stage]
- bar_list = list(bars.get(ticker, []))
+ current_price = bar_list[-1]['close']
+ elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
- if SIM_MODE:
- # SIM: 최근 봉 고가가 목표가 이상이면 체결
- if bar_list and bar_list[-1]['high'] >= pos['sell_price']:
- _record_exit(ticker, pos['sell_price'], tag)
- continue
- else:
- # 실거래: API로 체결 확인
- state, avg_price = check_order_state(uuid)
- if state == 'done':
- _record_exit(ticker, avg_price or pos['sell_price'], tag)
- continue
- if state in ('cancel', None):
- _advance_stage(ticker)
- continue
+ # peak 갱신
+ pos['running_peak'] = max(pos['running_peak'], current_price)
- # ── LLM 어드바이저 (primary) ──────────────────────────────────────
- if _should_call_llm(pos, elapsed):
- pos['llm_last_ts'] = datetime.now()
- current_price = bar_list[-1]['close'] if bar_list else pos['sell_price']
- llm_sell = get_exit_price(ticker, pos, bar_list, current_price)
+ profit_pct = (current_price - pos['entry_price']) / pos['entry_price']
+ drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
- if llm_sell is not None and llm_sell.get('action') == 'sell':
- new_price = llm_sell['price']
- confidence = llm_sell.get('confidence', '?')
- reason = llm_sell.get('reason', '')
- status = llm_sell.get('market_status', '')
- watch = llm_sell.get('watch_needed', False)
- pnl_pct = (new_price - pos['entry_price']) / pos['entry_price'] * 100
+ # 1. 손절: -2%
+ if profit_pct <= -STOP_LOSS_PCT:
+ exit_price = do_sell_market(ticker, pos['qty']) or current_price
+ log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)")
+ _record_exit(ticker, exit_price, 'stoploss')
+ continue
- cancel_order_safe(uuid)
- new_uuid = submit_limit_sell(ticker, pos['qty'], new_price)
- pos['sell_uuid'] = new_uuid
- pos['sell_price'] = new_price
- pos['llm_active'] = True
- log.info(f"[매도/LLM] {ticker} 지정가 {fp(new_price)}원 설정")
- tg(
- f"🤖 LLM 매도 설정 {ticker}\n"
- f"지정가: {fp(new_price)}원 (진입 대비 {pnl_pct:+.2f}%)\n"
- f"확신: {confidence} 시장: {status} 관망: {'Y' if watch else 'N'}\n"
- f"LLM: {reason}"
- )
- continue
- else:
- reason = llm_sell.get('reason', 'hold') if llm_sell else '오류/무응답'
- watch = llm_sell.get('watch_needed', False) if llm_sell else False
- pos['llm_active'] = False
- log.info(f"[매도/LLM→fallback] {ticker} {reason} → cascade 대기")
+ # 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5%
+ if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT:
+ exit_price = do_sell_market(ticker, pos['qty']) or current_price
+ peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
+ log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%")
+ _record_exit(ticker, exit_price, 'trail')
+ continue
- # ── Cascade fallback: LLM 실패 시에만 단계 전환 ──────────────────
- if not pos.get('llm_active') and elapsed >= end:
- log.info(f"[매도/cascade] {ticker} {elapsed:.0f}초 경과 → 다음 단계")
- _advance_stage(ticker)
+ # 3. 타임아웃: 4시간
+ if elapsed >= TIMEOUT_SECS:
+ exit_price = do_sell_market(ticker, pos['qty']) or current_price
+ log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
+ _record_exit(ticker, exit_price, 'timeout')
+ continue
def update_positions(current_prices: dict) -> None:
- """tick마다 Trail Stop / Timeout 체크 — ③ 종료(300s) 이후에만 동작."""
- stage3_end = CASCADE_STAGES[2][1] # 300초
-
+ """tick마다 peak 갱신 (실시간 트레일링)."""
for ticker in list(positions.keys()):
if ticker not in current_prices:
continue
- pos = positions[ticker]
- price = current_prices[ticker]
- elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
+ pos = positions[ticker]
+ price = current_prices[ticker]
- # ③ 이전: peak 추적 안 함, Trail Stop 비활성
- if elapsed < stage3_end:
- continue
+ pos['running_peak'] = max(pos['running_peak'], price)
- # ③ 종료 직후 첫 틱: peak을 현재가로 초기화 (진입가 기준 제거)
- if not pos.get('trail_peak_set'):
- pos['running_peak'] = price
- pos['trail_peak_set'] = True
- else:
- pos['running_peak'] = max(pos['running_peak'], price)
-
- # 지정가 주문 중이면 Trail Stop 비활성
- if pos.get('sell_uuid') is not None:
- continue
-
- drop = (pos['running_peak'] - price) / pos['running_peak']
-
- if drop >= TRAIL_STOP_R:
+ # 실시간 손절 체크
+ profit_pct = (price - pos['entry_price']) / pos['entry_price']
+ if profit_pct <= -STOP_LOSS_PCT:
exit_price = do_sell_market(ticker, pos['qty']) or price
+ log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)")
+ _record_exit(ticker, exit_price, 'stoploss')
+ continue
+
+ # 실시간 트레일링 체크
+ drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
+ if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
+ exit_price = do_sell_market(ticker, pos['qty']) or price
+ log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}원")
_record_exit(ticker, exit_price, 'trail')
- elif elapsed >= TIMEOUT_SECS and price <= pos['entry_price']:
- exit_price = do_sell_market(ticker, pos['qty']) or price
- _record_exit(ticker, exit_price, 'timeout')
# ── 메인 ──────────────────────────────────────────────────────────────────────
@@ -643,73 +655,101 @@ def preload_bars() -> None:
def restore_positions() -> None:
- """Upbit 잔고에서 보유 종목을 positions에 복구 (재시작 대응)."""
+ """Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응)."""
if SIM_MODE:
return
try:
balances = upbit_client.get_balances()
+ log.info(f"[복구] 잔고 조회: {len(balances)}건")
for b in balances:
currency = b.get('currency', '')
- bal = float(b.get('balance', 0)) + float(b.get('locked', 0))
+ bal = float(b.get('balance', 0))
+ locked = float(b.get('locked', 0))
avg = float(b.get('avg_buy_price', 0))
- if currency == 'KRW' or bal <= 0 or avg <= 0:
+ total = bal + locked
+ if currency == 'KRW' or total <= 0 or avg <= 0:
continue
ticker = f'KRW-{currency}'
if ticker not in TICKERS:
+ log.info(f"[복구] {ticker} TICKERS 외 → 스킵")
continue
if ticker in positions:
continue
- # 기존 미체결 매도 주문 전부 취소 후 새로 제출
+ log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}원")
+ # 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리)
try:
old_orders = upbit_client.get_order(ticker, state='wait') or []
for o in (old_orders if isinstance(old_orders, list) else []):
if o.get('side') == 'ask':
cancel_order_safe(o.get('uuid'))
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
- except Exception:
- pass
+ except Exception as e:
+ log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
# 취소 후 실제 가용 수량 재조회
time.sleep(0.5)
- actual_bal = upbit_client.get_balance(currency) or bal
- _, _, lr, stag = CASCADE_STAGES[0]
- target = avg * (1 + lr)
- sell_uuid = submit_limit_sell(ticker, actual_bal, target)
+ actual_bal = upbit_client.get_balance(currency)
+ if not actual_bal or actual_bal <= 0:
+ actual_bal = total
+ log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용")
positions[ticker] = {
'entry_price': avg,
- 'entry_ts': datetime.now() - timedelta(seconds=LLM_MIN_ELAPSED), # LLM 즉시 활성
+ 'entry_ts': datetime.now(),
'running_peak': avg,
'qty': actual_bal,
- 'stage': 0,
- 'sell_uuid': sell_uuid,
- 'sell_price': target,
- 'llm_last_ts': None,
- 'llm_active': False,
}
- log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원")
+ log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링")
tg(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}")
- if positions:
- log.info(f"[복구] 총 {len(positions)}개 포지션 복구됨")
+
+ # 미체결 매수 주문 복구 → pending_buys
+ for ticker in TICKERS:
+ if ticker in positions or ticker in pending_buys:
+ continue
+ try:
+ orders = upbit_client.get_order(ticker, state='wait') or []
+ for o in (orders if isinstance(orders, list) else []):
+ if o.get('side') == 'bid':
+ price = float(o.get('price', 0))
+ rem = float(o.get('remaining_volume', 0))
+ if price > 0 and rem > 0:
+ pending_buys[ticker] = {
+ 'uuid': o.get('uuid'),
+ 'price': price,
+ 'qty': rem,
+ 'ts': datetime.now(),
+ 'vol_ratio': 0,
+ }
+ log.info(f"[복구] {ticker} 미체결 매수 복구: {fp(price)}원 수량:{rem:.6f}")
+ break
+ except Exception:
+ pass
+
+ restored = len(positions) + len(pending_buys)
+ if restored:
+ log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
+ # 복구 결과를 position_sync에 반영
+ for ticker, pos in positions.items():
+ sync_position(ticker, 'PENDING_SELL', buy_price=pos['entry_price'],
+ qty=pos['qty'],
+ invested_krw=int(pos['qty'] * pos['entry_price']))
+ for ticker, pb in pending_buys.items():
+ sync_position(ticker, 'PENDING_BUY', buy_price=pb['price'],
+ qty=pb['qty'], order_uuid=pb.get('uuid'),
+ invested_krw=int(pb['qty'] * pb['price']))
except Exception as e:
- log.warning(f"[복구] 잔고 조회 실패: {e}")
+ log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
def main():
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== tick_trader 시작 ({mode}) ===")
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
- stage_nums = ['①','②','③','④','⑤','⑥']
- stage_desc = ' → '.join(
- f"{stage_nums[i]} {s[1]}초 +{s[2]*100:.1f}%" for i, s in enumerate(CASCADE_STAGES)
- )
- log.info(f"청산: {stage_desc} → {stage_nums[len(CASCADE_STAGES)]} Trail -{TRAIL_STOP_R*100:.1f}% (지정가→시장가)")
+ log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
tg(
f"🚀 tick_trader 시작 ({mode})\n"
- f"봉주기 20초 | VOL ≥ {VOL_MIN}x | 최대 {MAX_POS}포지션\n"
- f"① 40초 +2.0% 지정가\n"
- f"② 100초 +1.0% 지정가\n"
- f"③ 700초 +0.5% 지정가\n"
- f"④ 3100초 +0.1% 지정가\n"
- f"⑤ Trail -{TRAIL_STOP_R*100:.1f}% 시장가"
+ 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()
@@ -745,7 +785,7 @@ def main():
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} [{CASCADE_STAGES[p['stage']][3] if p['stage'] < len(CASCADE_STAGES) else '⑤'}]"
+ 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}")