"""WebSocket 기반 20초봉 트레이더. 구조: WebSocket -> trade tick 수신 -> 20초봉 집계 -> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수 -> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h) 실행: .venv/bin/python3 daemons/tick_trader.py 로그: /tmp/tick_trader.log """ import sys, os, time, logging, threading, requests, math from datetime import datetime, timedelta from collections import deque, defaultdict from typing import Optional sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from dotenv import load_dotenv load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')) from core.llm_advisor import get_exit_price, get_entry_price import pyupbit import oracledb # ── 전략 파라미터 ────────────────────────────────────────────────────────────── TICKERS = [ 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN', 'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR', ] BAR_SEC = 20 # 봉 주기 (초) VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수 ATR_LOOKBACK = 28 # ATR 계산 봉 수 VOL_MIN = 5.0 # 거래량 배수 임계값 VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터 BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초) MAX_POS = int(os.environ.get('MAX_POSITIONS', 5)) MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000)) PER_POS = MAX_BUDGET // MAX_POS FEE = 0.0005 # 트레일링 스탑 청산 TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도 MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5% STOP_LOSS_PCT = 0.02 # -2% 손절 TIMEOUT_SECS = 14400 # 4시간 SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true' upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY']) TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '') TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '') # ── 로깅 ────────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', handlers=[ logging.FileHandler('/tmp/tick_trader.log'), ] ) log = logging.getLogger(__name__) # ── position_sync DB ───────────────────────────────────────────────────────── _db_conn = None def _get_db(): global _db_conn if _db_conn is None: kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"], dsn=os.environ["ORACLE_DSN"]) if w := os.environ.get("ORACLE_WALLET"): kwargs["config_dir"] = w _db_conn = oracledb.connect(**kwargs) return _db_conn def sync_position(ticker: str, state: str, buy_price=None, sell_price=None, qty=None, order_uuid=None, invested_krw=None): """position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신.""" try: conn = _get_db() cur = conn.cursor() if state == 'IDLE': cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker]) else: now = datetime.now() cur.execute( """MERGE INTO position_sync ps USING (SELECT :1 AS ticker FROM dual) src ON (ps.ticker = src.ticker) WHEN MATCHED THEN UPDATE SET state = :2, buy_price = :3, sell_price = :4, qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8 WHEN NOT MATCHED THEN INSERT (ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at) VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""", [ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now, ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now]) conn.commit() except Exception as e: log.warning(f"[sync_position] {ticker} {state} 실패: {e}") global _db_conn _db_conn = None def fp(price: float) -> str: """가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시.""" if price >= 100: return f"{price:,.0f}" elif price >= 10: return f"{price:,.1f}" else: return f"{price:,.2f}" def tg(msg: str) -> None: if not TG_TOKEN or not TG_CHAT_ID: return try: requests.post( f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage', json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'}, timeout=5, ) except Exception as e: log.warning(f'Telegram 전송 실패: {e}') # ── 20초봉 집계 ─────────────────────────────────────────────────────────────── bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10)) cur_bar: dict = {} bar_lock = threading.Lock() def _new_bar(price: float, volume: float, ts: datetime) -> dict: return {'open': price, 'high': price, 'low': price, 'close': price, 'volume': volume, 'ts': ts} def on_tick(ticker: str, price: float, volume: float) -> None: with bar_lock: if ticker not in cur_bar: cur_bar[ticker] = _new_bar(price, volume, datetime.now()) return b = cur_bar[ticker] b['high'] = max(b['high'], price) b['low'] = min(b['low'], price) b['close'] = price b['volume'] += volume def finalize_bars() -> None: """BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인.""" while True: time.sleep(BAR_SEC) now = datetime.now() signals = [] with bar_lock: for ticker in list(cur_bar.keys()): b = cur_bar[ticker] if b['volume'] == 0: continue bars[ticker].append(b) cur_bar[ticker] = _new_bar(b['close'], 0, now) sig = detect_signal(ticker) if sig: signals.append(sig) # bar_lock 밖에서 LLM 호출 + 체결 확인 for sig in signals: process_signal(sig) check_pending_buys() check_filled_positions() # ── 지표 계산 ───────────────────────────────────────────────────────────────── def calc_vr(bar_list: list, idx: int) -> float: start = max(0, idx - VOL_LOOKBACK) end = max(0, idx - 2) baseline = sorted(bar_list[i]['volume'] for i in range(start, end)) if not baseline: return 0.0 # 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지 trim = max(1, len(baseline) // 10) trimmed = baseline[:len(baseline) - trim] if not trimmed: return 0.0 avg = sum(trimmed) / len(trimmed) return bar_list[idx]['volume'] / avg if avg > 0 else 0.0 def calc_atr(bar_list: list) -> float: if len(bar_list) < ATR_LOOKBACK + 2: return 0.0 trs = [] for i in range(-ATR_LOOKBACK - 1, -1): b = bar_list[i] bp = bar_list[i - 1] tr = max(b['high'] - b['low'], abs(b['high'] - bp['close']), abs(b['low'] - bp['close'])) trs.append(tr) prev_close = bar_list[-2]['close'] return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0 # ── 시그널 감지 (완화 — LLM이 최종 판단) ──────────────────────────────────── def detect_signal(ticker: str) -> Optional[dict]: """양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출.""" bar_list = list(bars[ticker]) n = len(bar_list) if n < VOL_LOOKBACK + 5: return None if ticker in positions or ticker in pending_buys: return None if len(positions) + len(pending_buys) >= MAX_POS: return None b = bar_list[-1] if b['close'] <= b['open']: return None vr = calc_vr(bar_list, n - 1) if vr < VOL_MIN: return None # 20초봉 거래대금 하드캡: 소량 조작 방지 bar_krw = b['close'] * b['volume'] if bar_krw < VOL_KRW_MIN: return None # ── LLM 호출 절감: skip 패턴 사전 필터 ── # 1) 횡보 (최근 15봉 변동폭 < 0.3%) → 매수 매력 없음 recent = bar_list[-15:] period_high = max(x['high'] for x in recent) period_low = min(x['low'] for x in recent) if period_low > 0: spread_pct = (period_high - period_low) / period_low * 100 if spread_pct < 0.3: log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵") return None # 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달) long_bars = bar_list[-90:] # ~30분 long_high = max(x['high'] for x in long_bars) long_low = min(x['low'] for x in long_bars) if long_high > long_low: pos_in_range = (b['close'] - long_low) / (long_high - long_low) if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0: log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.1f}% → 스킵") return None # 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉이어야 진입 prev_greens = 0 for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1): if bar_list[k]['close'] > bar_list[k]['open']: prev_greens += 1 else: break if prev_greens < 2: log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 → 스킵") return None return { 'ticker': ticker, 'price': b['close'], 'vol_ratio': vr, 'bar_list': bar_list, } # ── 주문 ────────────────────────────────────────────────────────────────────── def _round_price(price: float) -> float: """Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지).""" if price >= 2_000_000: unit = 1000 elif price >= 1_000_000: unit = 500 elif price >= 100_000: unit = 100 elif price >= 10_000: unit = 10 elif price >= 1_000: unit = 5 elif price >= 100: unit = 1 elif price >= 10: unit = 0.1 else: unit = 0.01 return math.floor(price / unit) * unit def submit_limit_sell(ticker: str, qty: float, price: float) -> Optional[str]: """지정가 매도 주문. Returns UUID.""" price = _round_price(price) log.debug(f"[매도주문] {ticker} price={price} qty={qty}") if SIM_MODE: return f"sim-{ticker}" try: order = upbit_client.sell_limit_order(ticker, price, qty) if not order or 'error' in str(order): log.error(f"지정가 매도 제출 실패 {ticker}: price={price} qty={qty} → {order}") return None return order.get('uuid') except Exception as e: log.error(f"지정가 매도 오류 {ticker}: {e}") return None def cancel_order_safe(uuid: Optional[str]) -> None: if SIM_MODE or not uuid or uuid.startswith('sim-'): return try: upbit_client.cancel_order(uuid) except Exception as e: log.warning(f"주문 취소 실패 {uuid}: {e}") def check_order_state(uuid: str) -> tuple: """Returns (state, avg_price). state: 'done'|'wait'|'cancel'|None""" try: detail = upbit_client.get_order(uuid) if not detail: return None, None state = detail.get('state') avg_price = float(detail.get('avg_price') or 0) or None return state, avg_price except Exception as e: log.warning(f"주문 조회 실패 {uuid}: {e}") return None, None def _avg_price_from_order(uuid: str) -> Optional[float]: try: detail = upbit_client.get_order(uuid) if not detail: return None trades = detail.get('trades', []) if trades: total_funds = sum(float(t['funds']) for t in trades) total_vol = sum(float(t['volume']) for t in trades) return total_funds / total_vol if total_vol > 0 else None avg = detail.get('avg_price') return float(avg) if avg else None except Exception as e: log.warning(f"체결가 조회 실패 {uuid}: {e}") return None def do_sell_market(ticker: str, qty: float) -> Optional[float]: """Trail Stop / Timeout용 시장가 매도.""" if SIM_MODE: price = pyupbit.get_current_price(ticker) log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}") return price try: order = upbit_client.sell_market_order(ticker, qty) if not order or 'error' in str(order): log.error(f"시장가 매도 실패: {order}") return None uuid = order.get('uuid') time.sleep(1.5) avg_price = _avg_price_from_order(uuid) if uuid else None return avg_price or pyupbit.get_current_price(ticker) except Exception as e: log.error(f"시장가 매도 오류 {ticker}: {e}") return None # ── 지정가 매수 (LLM 판단) ─────────────────────────────────────────────────── pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio} def process_signal(sig: dict) -> None: """시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출.""" ticker = sig['ticker'] bar_list = sig['bar_list'] cur_price = sig['price'] vol_ratio = sig['vol_ratio'] # 이미 보유/매수대기 중인 종목 중복 방지 if ticker in positions or ticker in pending_buys: return # LLM 호출 전 포지션 수 재확인 (동시 진행 방지) if len(positions) + len(pending_buys) >= MAX_POS: log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵") return log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x → LLM 판단 요청") llm_result = get_entry_price( ticker=ticker, signal=sig, bar_list=bar_list, current_price=cur_price, num_positions=len(positions), max_positions=MAX_POS, ) if llm_result is None or llm_result.get('action') != 'buy': reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답' status = llm_result.get('market_status', '') if llm_result else '' log.info(f"[매수/LLM] {ticker} → 스킵 | {reason}") tg( f"⏭️ 매수 스킵 {ticker}\n" f"현재가: {fp(cur_price)}원 볼륨: {vol_ratio:.1f}x\n" f"시장: {status}\n" f"사유: {reason}" ) return # LLM 호출 후 포지션 수/중복 재확인 if ticker in positions or ticker in pending_buys: return if len(positions) + len(pending_buys) >= MAX_POS: log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵") return buy_price = _round_price(cur_price) # 현재가로 즉시 매수 confidence = llm_result.get('confidence', '?') reason = llm_result.get('reason', '') status = llm_result.get('market_status', '') # 예산 체크: MAX_BUDGET - 현재 투자금 합계 invested = sum(p['entry_price'] * p['qty'] for p in positions.values()) invested += sum(p['price'] * p['qty'] for p in pending_buys.values()) remaining = MAX_BUDGET - invested invest_amt = min(PER_POS, remaining) if invest_amt < 5000: log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f}원 → 스킵") return qty = invest_amt * (1 - FEE) / buy_price log.info(f"[매수/LLM] {ticker} → 승인 {fp(buy_price)}원 (현재가 매수)") if SIM_MODE: uuid = f"sim-buy-{ticker}" else: try: order = upbit_client.buy_limit_order(ticker, buy_price, qty) if not order or 'error' in str(order): log.error(f"지정가 매수 제출 실패: {order}") return uuid = order.get('uuid') except Exception as e: log.error(f"지정가 매수 오류 {ticker}: {e}") return pending_buys[ticker] = { 'uuid': uuid, 'price': buy_price, 'qty': qty, 'ts': datetime.now(), 'vol_ratio': vol_ratio, } sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty, order_uuid=uuid, invested_krw=int(qty * buy_price)) log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}") invested = int(qty * buy_price) tg( f"📥 지정가 매수 {ticker}\n" f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n" f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n" f"확신: {confidence} 시장: {status}\n" f"LLM: {reason}\n" f"{'[시뮬]' if SIM_MODE else '[실거래]'}" ) def check_pending_buys() -> None: """지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소.""" for ticker in list(pending_buys.keys()): pb = pending_buys[ticker] elapsed = (datetime.now() - pb['ts']).total_seconds() # 포지션 한도 초과 시 미체결 주문 즉시 취소 if len(positions) >= MAX_POS: cancel_order_safe(pb['uuid']) log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소") sync_position(ticker, 'IDLE') del pending_buys[ticker] continue if SIM_MODE: bar_list = list(bars.get(ticker, [])) if bar_list and bar_list[-1]['low'] <= pb['price']: log.info(f"[SIM 매수체결] {ticker} {fp(pb['price'])}원") _activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio']) del pending_buys[ticker] continue else: state, avg_price = check_order_state(pb['uuid']) if state == 'done': actual_price = avg_price or pb['price'] actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty'] _activate_position(ticker, actual_price, actual_qty, pb['vol_ratio']) del pending_buys[ticker] continue # 타임아웃 if elapsed >= BUY_TIMEOUT: cancel_order_safe(pb['uuid']) log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소") tg(f"❌ 매수 취소 {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 '[실거래]'}" ) # ── 포지션 관리 ─────────────────────────────────────────────────────────────── positions: dict = {} def _record_exit(ticker: str, exit_price: float, tag: str) -> None: """체결 완료 후 포지션 종료 처리.""" pos = positions[ticker] pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100 krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2 held = int((datetime.now() - pos['entry_ts']).total_seconds()) reason_tag = { 'trail': '트레일스탑', 'timeout': '타임아웃', 'stoploss': '손절', 'llm': 'LLM 매도', }.get(tag, tag) icon = "✅" if pnl > 0 else "🔴" log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유") invested = int(pos['qty'] * pos['entry_price']) tg( f"{icon} 청산 {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 check_filled_positions() -> None: """20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃.""" for ticker in list(positions.keys()): if ticker not in positions: continue pos = positions[ticker] bar_list = list(bars.get(ticker, [])) if not bar_list: continue current_price = bar_list[-1]['close'] elapsed = (datetime.now() - pos['entry_ts']).total_seconds() # peak 갱신 pos['running_peak'] = max(pos['running_peak'], current_price) profit_pct = (current_price - pos['entry_price']) / pos['entry_price'] drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0 # 1. 손절: -2% if profit_pct <= -STOP_LOSS_PCT: exit_price = do_sell_market(ticker, pos['qty']) or current_price log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)") _record_exit(ticker, exit_price, 'stoploss') continue # 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5% if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT: exit_price = do_sell_market(ticker, pos['qty']) or current_price peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100 log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%") _record_exit(ticker, exit_price, 'trail') continue # 3. 타임아웃: 4시간 if elapsed >= TIMEOUT_SECS: exit_price = do_sell_market(ticker, pos['qty']) or current_price log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과") _record_exit(ticker, exit_price, 'timeout') continue def update_positions(current_prices: dict) -> None: """tick마다 peak 갱신 (실시간 트레일링).""" for ticker in list(positions.keys()): if ticker not in current_prices: continue pos = positions[ticker] price = current_prices[ticker] pos['running_peak'] = max(pos['running_peak'], price) # 실시간 손절 체크 profit_pct = (price - pos['entry_price']) / pos['entry_price'] if profit_pct <= -STOP_LOSS_PCT: exit_price = do_sell_market(ticker, pos['qty']) or price log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)") _record_exit(ticker, exit_price, 'stoploss') continue # 실시간 트레일링 체크 drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0 if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT: exit_price = do_sell_market(ticker, pos['qty']) or price log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}원") _record_exit(ticker, exit_price, 'trail') # ── 메인 ────────────────────────────────────────────────────────────────────── def preload_bars() -> None: need_min = (VOL_LOOKBACK + 10) // 3 + 1 log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...") loaded = 0 for ticker in TICKERS: for attempt in range(3): try: df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min) if df is None or df.empty: time.sleep(0.5) continue with bar_lock: for _, row in df.iterrows(): o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close']) v3 = float(row['volume']) / 3 ts = row.name.to_pydatetime() for _ in range(3): bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts}) loaded += 1 break except Exception as e: log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}") time.sleep(1) time.sleep(0.2) log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커") def restore_positions() -> None: """Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응).""" if SIM_MODE: return try: balances = upbit_client.get_balances() log.info(f"[복구] 잔고 조회: {len(balances)}건") for b in balances: currency = b.get('currency', '') bal = float(b.get('balance', 0)) locked = float(b.get('locked', 0)) avg = float(b.get('avg_buy_price', 0)) total = bal + locked if currency == 'KRW' or total <= 0 or avg <= 0: continue ticker = f'KRW-{currency}' if ticker not in TICKERS: log.info(f"[복구] {ticker} TICKERS 외 → 스킵") continue if ticker in positions: continue log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}원") # 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리) try: old_orders = upbit_client.get_order(ticker, state='wait') or [] for o in (old_orders if isinstance(old_orders, list) else []): if o.get('side') == 'ask': cancel_order_safe(o.get('uuid')) log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}") except Exception as e: log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}") # 취소 후 실제 가용 수량 재조회 time.sleep(0.5) actual_bal = upbit_client.get_balance(currency) if not actual_bal or actual_bal <= 0: actual_bal = total log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용") positions[ticker] = { 'entry_price': avg, 'entry_ts': datetime.now(), 'running_peak': avg, 'qty': actual_bal, } log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링") tg(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}") # 미체결 매수 주문 복구 → pending_buys for ticker in TICKERS: if ticker in positions or ticker in pending_buys: continue try: orders = upbit_client.get_order(ticker, state='wait') or [] for o in (orders if isinstance(orders, list) else []): if o.get('side') == 'bid': price = float(o.get('price', 0)) rem = float(o.get('remaining_volume', 0)) if price > 0 and rem > 0: pending_buys[ticker] = { 'uuid': o.get('uuid'), 'price': price, 'qty': rem, 'ts': datetime.now(), 'vol_ratio': 0, } log.info(f"[복구] {ticker} 미체결 매수 복구: {fp(price)}원 수량:{rem:.6f}") break except Exception: pass restored = len(positions) + len(pending_buys) if restored: log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨") # 복구 결과를 position_sync에 반영 for ticker, pos in positions.items(): sync_position(ticker, 'PENDING_SELL', buy_price=pos['entry_price'], qty=pos['qty'], invested_krw=int(pos['qty'] * pos['entry_price'])) for ticker, pb in pending_buys.items(): sync_position(ticker, 'PENDING_BUY', buy_price=pb['price'], qty=pb['qty'], order_uuid=pb.get('uuid'), invested_krw=int(pb['qty'] * pb['price'])) except Exception as e: log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True) def main(): mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션" log.info(f"=== tick_trader 시작 ({mode}) ===") log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원") log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h") tg( f"🚀 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()