"""OpenRouter LLM 기반 매도 목표가 어드바이저. 1분 주기로 보유 포지션의 OHLCV 흐름을 분석해 최적 지정가 매도 목표가를 반환. LLM이 주(primary), 기존 cascade 규칙이 fallback. 프롬프트에 포함되는 시장 데이터: - 오늘 일봉 (고가/저가/거래량) - 최근 4시간 1시간봉 (가격/볼륨 흐름) - 최근 20봉 20초봉 (단기 패턴) LLM에게 제공하는 DB Tool (OpenAI function calling): - get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱) - get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용) - get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스) 반환값: float → 새 지정가 목표가 None → hold (현재 주문 유지) 또는 오류 """ from __future__ import annotations import json import logging import os from datetime import datetime from typing import Optional log = logging.getLogger(__name__) # 프롬프트에 포함할 봉 수 INPUT_BARS = 20 # ── Oracle DB Tools ────────────────────────────────────────────────────────── def _get_conn(): """Oracle ADB 연결 (.env 기반).""" 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 return oracledb.connect(**kwargs) def _tool_get_price_ticks(ticker: str, minutes: int = 10) -> str: """Oracle price_tick 테이블에서 최근 N분 가격 틱 조회.""" try: conn = _get_conn() cur = conn.cursor() cur.execute( """SELECT ts, price FROM price_tick WHERE ticker = :t AND ts >= SYSTIMESTAMP - NUMTODSINTERVAL(:m, 'MINUTE') ORDER BY ts DESC FETCH FIRST 100 ROWS ONLY""", {'t': ticker, 'm': minutes}, ) rows = cur.fetchall() conn.close() if not rows: return f"{ticker} 최근 {minutes}분 틱 데이터 없음" lines = [f" {r[0].strftime('%H:%M:%S')} {float(r[1]):>12,.2f}원" for r in rows] return f"{ticker} 최근 {minutes}분 가격 틱 ({len(rows)}건):\n" + "\n".join(lines) except Exception as e: return f"DB 오류: {e}" def _tool_get_ohlcv(ticker: str, limit: int = 30) -> str: """Oracle backtest_ohlcv 1분봉 최근 N개 조회 (지지/저항 파악용).""" try: conn = _get_conn() 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' ORDER BY ts DESC FETCH FIRST :n ROWS ONLY""", {'t': ticker, 'n': limit}, ) rows = cur.fetchall() conn.close() if not rows: return f"{ticker} 1분봉 데이터 없음" lines = [ f" {r[0].strftime('%H:%M')} O{float(r[1]):>10,.0f} H{float(r[2]):>10,.0f}" f" L{float(r[3]):>10,.0f} C{float(r[4]):>10,.0f} V{float(r[5]):.0f}" for r in reversed(rows) ] return f"{ticker} 1분봉 최근 {len(rows)}개:\n" + "\n".join(lines) except Exception as e: return f"DB 오류: {e}" # ── 종목 컨텍스트 조회 ──────────────────────────────────────────────────────── def _tool_get_context(ticker: str) -> str: """ticker_context 테이블에서 종목의 가격 통계 + 뉴스 조회.""" try: conn = _get_conn() cur = conn.cursor() cur.execute( "SELECT context_type, content FROM ticker_context WHERE ticker = :t", {'t': ticker}, ) rows = cur.fetchall() conn.close() if not rows: return f"{ticker} 컨텍스트 데이터 없음" parts = [] for ctx_type, content in rows: parts.append(f"[{ctx_type}]\n{content}") return f"{ticker} 종목 컨텍스트:\n" + "\n\n".join(parts) except Exception as e: return f"DB 오류: {e}" # ── Tool 정의 (OpenAI function calling 형식) ───────────────────────────────── _TOOLS = [ { 'type': 'function', 'function': { 'name': 'get_price_ticks', 'description': ( 'Oracle DB에서 특정 종목의 최근 N분간 가격 틱 데이터를 조회합니다. ' '단기 가격 흐름과 지지/저항 수준 파악에 사용하세요.' ), 'parameters': { 'type': 'object', 'properties': { 'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'}, 'minutes': {'type': 'integer', 'description': '최근 N분 데이터 (기본 10)'}, }, 'required': ['ticker'], }, }, }, { 'type': 'function', 'function': { 'name': 'get_ohlcv', 'description': ( 'Oracle DB에서 특정 종목의 1분봉 OHLCV 데이터를 조회합니다. ' '지지선/저항선 파악과 추세 분석에 사용하세요.' ), 'parameters': { 'type': 'object', 'properties': { 'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'}, 'limit': {'type': 'integer', 'description': '조회할 봉 수 (기본 30)'}, }, 'required': ['ticker'], }, }, }, { 'type': 'function', 'function': { 'name': 'get_ticker_context', 'description': ( '종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, ' '최근 뉴스 등 중장기 컨텍스트를 제공합니다. ' '매도 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.' ), 'parameters': { 'type': 'object', 'properties': { 'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'}, }, 'required': ['ticker'], }, }, }, ] def _execute_tool(tool_name: str, tool_input: dict) -> str: """LLM이 요청한 tool을 실행하고 결과를 반환.""" if tool_name == 'get_price_ticks': return _tool_get_price_ticks( ticker = tool_input['ticker'], minutes = tool_input.get('minutes', 10), ) if tool_name == 'get_ohlcv': return _tool_get_ohlcv( ticker = tool_input['ticker'], limit = tool_input.get('limit', 30), ) if tool_name == 'get_ticker_context': return _tool_get_context(ticker=tool_input['ticker']) return f'알 수 없는 tool: {tool_name}' # ── 시장 컨텍스트 수집 ─────────────────────────────────────────────────────── def _get_market_context(ticker: str) -> str: """오늘 일봉 요약 + 최근 4시간 1시간봉을 LLM 프롬프트용 텍스트로 반환. pyupbit API 호출 실패 시 빈 문자열 반환 (graceful degradation). """ try: import pyupbit lines: list[str] = [] # 오늘 일봉 (오늘 + 전일 2개) day_df = pyupbit.get_ohlcv(ticker, interval='day', count=2) if day_df is not None and not day_df.empty: today = day_df.iloc[-1] prev = day_df.iloc[-2] if len(day_df) > 1 else None vol_note = '' if prev is not None and prev['volume'] > 0: vol_note = f' (전일 대비 {today["volume"] / prev["volume"]:.1f}x)' lines.append('[오늘 일봉]') lines.append(f' 고가 {today["high"]:>12,.0f}원 저가 {today["low"]:>12,.0f}원') lines.append(f' 시가 {today["open"]:>12,.0f}원 거래량 {today["volume"]:,.0f}{vol_note}') # 최근 4시간 1시간봉 h1_df = pyupbit.get_ohlcv(ticker, interval='minute60', count=4) if h1_df is not None and not h1_df.empty: lines.append(f'[최근 {len(h1_df)}시간봉]') for ts, row in h1_df.iterrows(): lines.append( f' {ts.strftime("%H:%M")} ' f'고{row["high"]:>10,.0f} 저{row["low"]:>10,.0f} ' f'종{row["close"]:>10,.0f} 거래량{row["volume"]:,.0f}' ) return '\n'.join(lines) except Exception as e: log.debug(f'[LLM] 시장 컨텍스트 조회 실패: {e}') return '' # ── 프롬프트 빌더 ───────────────────────────────────────────────────────────── def _describe_bars(bar_list: list[dict], current_price: float) -> str: """봉 데이터를 LLM이 읽기 쉬운 텍스트로 변환.""" recent = bar_list[-INPUT_BARS:] if len(bar_list) >= INPUT_BARS else bar_list if not recent: return '봉 데이터 없음' lines = [] for b in recent: ts_str = b['ts'].strftime('%H:%M:%S') if isinstance(b.get('ts'), datetime) else '' lines.append( f' {ts_str} 종가{b["close"]:>10,.0f} 고가{b["high"]:>10,.0f}' f' 저가{b["low"]:>10,.0f}' ) highs5 = [b['high'] for b in recent[-5:]] closes5 = [b['close'] for b in recent[-5:]] period_high = max(b['high'] for b in recent) from_peak = (current_price - period_high) / period_high * 100 trend_h = '하락▼' if highs5[-1] < highs5[0] else '상승▲' if highs5[-1] > highs5[0] else '횡보─' trend_c = '하락▼' if closes5[-1] < closes5[0] else '상승▲' if closes5[-1] > closes5[0] else '횡보─' summary = ( f'\n[패턴 요약]\n' f'- 고가 추세: {trend_h} ({highs5[0]:,.0f}→{highs5[-1]:,.0f})\n' f'- 종가 추세: {trend_c} ({closes5[0]:,.0f}→{closes5[-1]:,.0f})\n' f'- 구간 최고가: {period_high:,.0f}원 현재가 대비: {from_peak:+.2f}%' ) return '\n'.join(lines) + summary def _build_prompt( ticker: str, entry_price: float, current_price: float, elapsed_min: float, current_target: float, bar_desc: str, market_context: 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 '' return f"""당신은 암호화폐 단기 트레이더입니다. 아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요. 필요하면 제공된 DB tool을 호출해 추가 데이터를 조회하세요. 특히 get_ticker_context로 종목의 24h/7d 가격 변동, 거래량 추이, 최근 뉴스를 확인하세요. [현재 포지션] 종목 : {ticker} 진입가 : {entry_price:,.0f}원 현재가 : {current_price:,.0f}원 ({pnl_pct:+.2f}%) 보유시간: {elapsed_min:.0f}분 현재 지정가: {current_target:,.0f}원 (현재가 대비 {target_gap:+.2f}%, 미체결) {market_section} [최근 {INPUT_BARS}봉 (20초봉)] {bar_desc} [운용 정책 참고 — 최종 판단은 당신이 결정] - 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정) - 수익 목표: 진입가 대비 +0.5% ~ +2% 구간 - 체결 가능성이 낮으면 현실적인 목표가로 조정 권장 - 상승 여력이 있으면 hold 권장 반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요. 매도 지정가를 설정할 경우: {{"action": "sell", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": false}} 현재 주문을 유지할 경우: {{"action": "hold", "reason": "유지 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": true/false}} watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)""" # ── 메인 함수 ───────────────────────────────────────────────────────────────── def get_exit_price( ticker: str, pos: dict, bar_list: list[dict], current_price: float, ) -> Optional[float]: """LLM에게 매도 목표가를 물어본다. (DB tool 사용 가능) Args: ticker: 종목 코드 (예: 'KRW-XRP') pos: positions[ticker] 딕셔너리 bar_list: list(bars[ticker]) — 최신봉이 마지막 current_price: 현재 틱 가격 Returns: float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이) None → hold 또는 오류 """ import requests as _req api_key = os.environ.get('OPENROUTER_API_KEY', '') if not api_key: log.debug('[LLM] OPENROUTER_API_KEY 없음 → cascade fallback') return None model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4-5-20251001') try: entry_price = pos['entry_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) prompt = _build_prompt( ticker, entry_price, current_price, elapsed_min, current_target, bar_desc, market_context=mkt_ctx, ) headers = { 'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json', } messages = [{'role': 'user', 'content': prompt}] # Tool use 루프: LLM이 tool을 요청하면 실행 후 결과 전달 for _ in range(5): # 최대 5회 tool 호출 body = { 'model': model, 'max_tokens': 512, 'tools': _TOOLS, 'messages': messages, 'response_format': {'type': 'json_object'}, } resp = _req.post( 'https://openrouter.ai/api/v1/chat/completions', headers=headers, json=body, timeout=30, ) resp.raise_for_status() result = resp.json() choice = result['choices'][0] message = choice['message'] # tool_calls가 있으면 실행 tool_calls = message.get('tool_calls') if tool_calls: messages.append(message) # assistant 메시지 추가 for tc in tool_calls: fn_name = tc['function']['name'] fn_args = json.loads(tc['function']['arguments']) fn_result = _execute_tool(fn_name, fn_args) log.info(f'[LLM-Tool] {ticker} {fn_name}({fn_args}) 호출') messages.append({ 'role': 'tool', 'tool_call_id': tc['id'], 'content': fn_result, }) continue # 다시 LLM에게 결과 전달 # 최종 텍스트 응답 raw = (message.get('content') or '').strip() if not raw: log.warning(f'[LLM] {ticker} 빈 응답 → cascade fallback') return None # JSON 추출 (```json 블록이나 순수 JSON 모두 처리) if '```' in raw: import re m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL) raw = m.group(1) if m else raw data = json.loads(raw) break else: log.warning(f'[LLM] {ticker} tool 루프 초과 → cascade fallback') return None reason = data.get('reason', '') status = data.get('market_status', '') if data.get('action') == 'hold': log.info(f'[LLM] {ticker} → hold | {status} | {reason}') return None suggested = float(data['price']) confidence = data.get('confidence', '?') log.info(f'[LLM] {ticker} 지정가 교체: {current_target:,.0f} → {suggested:,.0f}원 | {confidence} | {status} | {reason}') return suggested except json.JSONDecodeError as e: log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:100]} → cascade fallback') return None except Exception as e: log.warning(f'[LLM] {ticker} 오류: {e} → cascade fallback') return None