"""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"⏭️ 매수 스킵 {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"📥 지정가 매수 {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"❌ 매수 취소 {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"🟢 매수 체결 {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} 청산 {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 _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"🚀 tick_trader 시작 ({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()