"""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% 시장가 실행: .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 # ── 전략 파라미터 ────────────────────────────────────────────────────────────── 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', ] BAR_SEC = 20 # 봉 주기 (초) VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수 ATR_LOOKBACK = 28 # ATR 계산 봉 수 VOL_MIN = 6.0 # 거래량 배수 임계값 BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초) 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 # 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 활성 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__) 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 = [bar_list[i]['volume'] for i in range(start, end)] if not baseline: return 0.0 avg = sum(baseline) / len(baseline) 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 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 >= 500_000: unit = 100 elif price >= 100_000: unit = 50 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) 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}") 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'] # 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 len(positions) + len(pending_buys) >= MAX_POS: log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵") return buy_price = _round_price(llm_result['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}%)") 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, } log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}") tg( f"📥 지정가 매수 {ticker}\n" f"지정가: {fp(buy_price)}원 (현재가 대비 {diff_pct:+.2f}%)\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}) 도달 → 취소") 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}초 미체결") 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)}원") tg( f"🟢 매수 체결 {ticker}\n" f"체결가: {fp(entry_price)}원 수량: {qty:.6f}\n" f"지정가 매도: {tag} {fp(target)}원 (+{lr*100:.1f}%)\n" f"{'[시뮬]' if SIM_MODE else '[실거래]'}" ) # ── 포지션 관리 ─────────────────────────────────────────────────────────────── 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: """체결 완료 후 포지션 종료 처리.""" 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 = { '①': '① +2.0% 익절', '②': '② +1.0% 익절', '③': '③ +0.5% 익절', '④': '④ +0.1% 본전', 'trail': '⑤ 트레일스탑', 'timeout': '⑤ 타임아웃', }.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}초 보유") tg( f"{icon} 청산 {ticker} [{reason_tag}] ({llm_flag})\n" f"진입: {fp(pos['entry_price'])}원\n" f"청산: {fp(exit_price)}원\n" f"PNL: {pnl:+.2f}% ({krw:+,.0f}원) {held}초 보유\n" f"{'[시뮬]' if SIM_MODE else '[실거래]'}" ) 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 (단계 시간 초과 → 다음 단계) """ 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)에서 처리 continue stage = pos['stage'] _, end, _, tag = CASCADE_STAGES[stage] bar_list = list(bars.get(ticker, [])) 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 # ── 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) 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 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 대기") # ── Cascade fallback: LLM 실패 시에만 단계 전환 ────────────────── if not pos.get('llm_active') and elapsed >= end: log.info(f"[매도/cascade] {ticker} {elapsed:.0f}초 경과 → 다음 단계") _advance_stage(ticker) def update_positions(current_prices: dict) -> None: """tick마다 Trail Stop / Timeout 체크 — ③ 종료(300s) 이후에만 동작.""" stage3_end = CASCADE_STAGES[2][1] # 300초 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() # ③ 이전: peak 추적 안 함, Trail Stop 비활성 if elapsed < stage3_end: continue # ③ 종료 직후 첫 틱: 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: exit_price = do_sell_market(ticker, pos['qty']) or 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') # ── 메인 ────────────────────────────────────────────────────────────────────── 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 잔고에서 보유 종목을 positions에 복구 (재시작 대응).""" if SIM_MODE: return try: balances = upbit_client.get_balances() for b in balances: currency = b.get('currency', '') bal = float(b.get('balance', 0)) + float(b.get('locked', 0)) avg = float(b.get('avg_buy_price', 0)) if currency == 'KRW' or bal <= 0 or avg <= 0: continue ticker = f'KRW-{currency}' if ticker not in TICKERS: continue if ticker in positions: continue # 기존 미체결 매도 주문 전부 취소 후 새로 제출 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 # 취소 후 실제 가용 수량 재조회 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) positions[ticker] = { 'entry_price': avg, 'entry_ts': datetime.now() - timedelta(seconds=LLM_MIN_ELAPSED), # LLM 즉시 활성 '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)}원") tg(f"♻️ 포지션 복구 {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}") if positions: log.info(f"[복구] 총 {len(positions)}개 포지션 복구됨") except Exception as e: log.warning(f"[복구] 잔고 조회 실패: {e}") 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}% (지정가→시장가)") 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}% 시장가" ) 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} [{CASCADE_STAGES[p['stage']][3] if p['stage'] < len(CASCADE_STAGES) else '⑤'}]" 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()