"""실시간 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"🟢 매수 완료 {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} 청산 {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: {pnl:+.2f}% ({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"🔔 시그널 {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"🚀 트레이더 시작 ({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()