diff --git a/core/llm_advisor.py b/core/llm_advisor.py index d036345..ebf11a9 100644 --- a/core/llm_advisor.py +++ b/core/llm_advisor.py @@ -1,21 +1,14 @@ -"""OpenRouter LLM 기반 매도 목표가 어드바이저. +"""OpenRouter LLM 기반 매매 어드바이저. -1분 주기로 보유 포지션의 OHLCV 흐름을 분석해 최적 지정가 매도 목표가를 반환. -LLM이 주(primary), 기존 cascade 규칙이 fallback. - -프롬프트에 포함되는 시장 데이터: - - 오늘 일봉 (고가/저가/거래량) - - 최근 4시간 1시간봉 (가격/볼륨 흐름) - - 최근 20봉 20초봉 (단기 패턴) +매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정 +매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback) 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 (현재 주문 유지) 또는 오류 + - get_trade_history(ticker): 최근 거래 이력 (승패, 손익) + - get_btc_trend(): BTC 최근 동향 (알트 매수 판단용) """ from __future__ import annotations @@ -121,6 +114,71 @@ def _tool_get_context(ticker: str) -> str: return f"DB 오류: {e}" +# ── 거래 이력 조회 ──────────────────────────────────────────────────────────── + +def _tool_get_trade_history(ticker: str, limit: int = 10) -> str: + """trade_results 테이블에서 해당 종목 최근 거래 이력 조회.""" + try: + conn = _get_conn() + cur = conn.cursor() + cur.execute( + """SELECT traded_at, is_win, pnl_pct, buy_price, sell_price, sell_reason + FROM trade_results + WHERE ticker = :t + ORDER BY traded_at DESC + FETCH FIRST :n ROWS ONLY""", + {'t': ticker, 'n': limit}, + ) + rows = cur.fetchall() + conn.close() + if not rows: + return f"{ticker} 거래 이력 없음" + lines = [] + wins = sum(1 for r in rows if r[1]) + for r in rows: + ts = r[0].strftime('%m/%d %H:%M') if r[0] else '?' + wl = '승' if r[1] else '패' + pnl = float(r[2]) if r[2] else 0 + reason = r[5] or '' + lines.append(f" {ts} {wl} {pnl:+.2f}% {reason}") + header = f"{ticker} 최근 {len(rows)}건 (승률 {wins}/{len(rows)}={wins/len(rows)*100:.0f}%):" + return header + "\n" + "\n".join(lines) + except Exception as e: + return f"DB 오류: {e}" + + +def _tool_get_btc_trend() -> str: + """BTC 최근 동향 (1시간봉 6개 + 일봉).""" + try: + import pyupbit + lines = [] + # 일봉 2개 + day_df = pyupbit.get_ohlcv('KRW-BTC', interval='day', count=2) + if day_df is not None and len(day_df) >= 2: + today = day_df.iloc[-1] + prev = day_df.iloc[-2] + chg = (today['close'] - prev['close']) / prev['close'] * 100 + lines.append(f"[BTC 일봉] 현재 {today['close']:,.0f}원 (전일 대비 {chg:+.2f}%)") + lines.append(f" 고가 {today['high']:,.0f} 저가 {today['low']:,.0f}") + + # 1시간봉 6개 + h1_df = pyupbit.get_ohlcv('KRW-BTC', interval='minute60', count=6) + if h1_df is not None and not h1_df.empty: + first_c = float(h1_df['close'].iloc[0]) + last_c = float(h1_df['close'].iloc[-1]) + h_chg = (last_c - first_c) / first_c * 100 + trend = '상승' if h_chg > 0.5 else '하락' if h_chg < -0.5 else '횡보' + lines.append(f"[BTC 6시간 추세] {trend} ({h_chg:+.2f}%)") + for ts, row in h1_df.iterrows(): + lines.append( + f" {ts.strftime('%H:%M')} 종{row['close']:>12,.0f} " + f"거래량{row['volume']:,.2f}" + ) + return "\n".join(lines) if lines else "BTC 데이터 조회 실패" + except Exception as e: + return f"BTC 조회 오류: {e}" + + # ── Tool 정의 (OpenAI function calling 형식) ───────────────────────────────── _TOOLS = [ @@ -167,7 +225,7 @@ _TOOLS = [ 'description': ( '종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, ' '최근 뉴스 등 중장기 컨텍스트를 제공합니다. ' - '매도 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.' + '매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.' ), 'parameters': { 'type': 'object', @@ -178,6 +236,39 @@ _TOOLS = [ }, }, }, + { + 'type': 'function', + 'function': { + 'name': 'get_trade_history', + 'description': ( + '특정 종목의 최근 거래 이력을 조회합니다. ' + '승률, 손익, 매도 사유 등을 확인해 이 종목의 과거 성과를 파악하세요.' + ), + 'parameters': { + 'type': 'object', + 'properties': { + 'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'}, + 'limit': {'type': 'integer', 'description': '조회 건수 (기본 10)'}, + }, + 'required': ['ticker'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_btc_trend', + 'description': ( + 'BTC(비트코인)의 최근 가격 동향을 조회합니다. ' + '알트코인 매수 전 BTC 추세를 반드시 확인하세요. ' + 'BTC 하락 시 알트코인 동반 하락 위험이 높습니다.' + ), + 'parameters': { + 'type': 'object', + 'properties': {}, + }, + }, + }, ] @@ -195,6 +286,13 @@ def _execute_tool(tool_name: str, tool_input: dict) -> str: ) if tool_name == 'get_ticker_context': return _tool_get_context(ticker=tool_input['ticker']) + if tool_name == 'get_trade_history': + return _tool_get_trade_history( + ticker=tool_input['ticker'], + limit=tool_input.get('limit', 10), + ) + if tool_name == 'get_btc_trend': + return _tool_get_btc_trend() return f'알 수 없는 tool: {tool_name}' @@ -319,7 +417,176 @@ def _build_prompt( watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)""" -# ── 메인 함수 ───────────────────────────────────────────────────────────────── +# ── 공통 LLM 호출 ──────────────────────────────────────────────────────────── + +def _call_llm(prompt: str, ticker: str) -> Optional[dict]: + """OpenRouter API를 호출하고 JSON 응답을 반환. 실패 시 None.""" + import requests as _req + import re + + api_key = os.environ.get('OPENROUTER_API_KEY', '') + if not api_key: + log.debug('[LLM] OPENROUTER_API_KEY 없음') + return None + + model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4.5') + headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + } + messages = [{'role': 'user', 'content': prompt}] + + try: + for _ in range(5): + 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 = message.get('tool_calls') + if tool_calls: + messages.append(message) + 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 + + raw = (message.get('content') or '').strip() + if not raw: + log.warning(f'[LLM] {ticker} 빈 응답') + return None + + # 코드블록 안 JSON 추출 + if '```' in raw: + m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL) + if m: + raw = m.group(1) + # 텍스트에 섞인 JSON 객체 추출 + try: + return json.loads(raw) + except json.JSONDecodeError as e: + m = re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', raw, re.DOTALL) + if m: + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + pass + log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:200]}') + return None + else: + log.warning(f'[LLM] {ticker} tool 루프 초과') + return None + except Exception as e: + log.warning(f'[LLM] {ticker} 오류: {e}') + return None + + +# ── 매수 판단 ──────────────────────────────────────────────────────────────── + +def get_entry_price( + ticker: str, + signal: dict, + bar_list: list[dict], + current_price: float, + fng: int = 50, + num_positions: int = 0, + max_positions: int = 3, +) -> Optional[float]: + """LLM에게 매수 여부 + 지정가를 물어본다. + + Args: + ticker: 종목 코드 + signal: 시그널 정보 {vol_ratio, prices, ...} + bar_list: 최근 20초봉 리스트 + current_price: 현재 가격 + fng: 현재 F&G 지수 + num_positions: 현재 보유 포지션 수 + max_positions: 최대 포지션 수 + + Returns: + float → 지정가 매수 가격 + None → 매수하지 않음 + """ + bar_desc = _describe_bars(bar_list, current_price) + mkt_ctx = _get_market_context(ticker) + + vol_ratio = signal.get('vol_ratio', 0) + market_section = f'\n{mkt_ctx}\n' if mkt_ctx else '' + + prompt = f"""당신은 암호화폐 단기 트레이더입니다. +아래 시그널을 분석해 **매수 여부와 지정가 매수 가격**을 판단하세요. +반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요: +- get_btc_trend: BTC 추세 확인 (필수 — BTC 하락 시 알트 매수 위험) +- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인 +- get_trade_history: 이 종목 과거 거래 성과 확인 +- get_ohlcv: 1분봉으로 지지/저항선 확인 + +[시그널 감지] +종목 : {ticker} +현재가 : {current_price:,.0f}원 +거래량비: {vol_ratio:.1f}x (61봉 평균 대비) +F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕'}) +포지션 : {num_positions}/{max_positions} +{market_section} +[최근 {INPUT_BARS}봉 (20초봉)] +{bar_desc} + +[판단 기준] +- 거래량 급증이 진짜 매집 신호인지, 일시적 노이즈인지 구분 +- BTC가 하락 중이면 알트코인 매수 자제 +- 최근 이 종목에서 연패 중이면 신중하게 +- 현재가보다 약간 낮은 지정가를 설정해 유리한 가격에 매수 +- 상승 추세가 이미 많이 진행됐으면 진입 자제 + +반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요. + +매수할 경우: +{{"action": "buy", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}} + +매수하지 않을 경우: +{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}""" + + data = _call_llm(prompt, ticker) + if data is None: + return None + + reason = data.get('reason', '') + status = data.get('market_status', '') + + if data.get('action') == 'skip': + log.info(f'[LLM매수] {ticker} → skip | {status} | {reason}') + return None + + if data.get('action') == 'buy': + price = float(data['price']) + confidence = data.get('confidence', '?') + log.info(f'[LLM매수] {ticker} → buy {price:,.0f}원 | {confidence} | {status} | {reason}') + return price + + log.warning(f'[LLM매수] {ticker} 알 수 없는 action: {data}') + return None + + +# ── 매도 판단 ──────────────────────────────────────────────────────────────── def get_exit_price( ticker: str, @@ -339,101 +606,30 @@ def get_exit_price( float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이) None → hold 또는 오류 """ - import requests as _req + 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 - api_key = os.environ.get('OPENROUTER_API_KEY', '') - if not api_key: - log.debug('[LLM] OPENROUTER_API_KEY 없음 → cascade fallback') + 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, + ) + + data = _call_llm(prompt, ticker) + if data is None: return None - model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4-5-20251001') + reason = data.get('reason', '') + status = data.get('market_status', '') - 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') + 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 diff --git a/daemons/tick_trader.py b/daemons/tick_trader.py index b163811..9e3f5c4 100644 --- a/daemons/tick_trader.py +++ b/daemons/tick_trader.py @@ -1,7 +1,9 @@ """WebSocket 기반 20초봉 트레이더. 구조: - WebSocket → trade tick 수신 → 20초봉 집계 → 3봉 가속 시그널(VOL≥8x) → cascade 청산 + WebSocket → trade tick 수신 → 20초봉 집계 + → 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수 + → LLM primary 매도 + cascade fallback 청산 cascade (초 기준): ① 0~ 40초: +2.0% 지정가 @@ -24,7 +26,7 @@ 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 +from core.llm_advisor import get_exit_price, get_entry_price import pyupbit @@ -41,7 +43,8 @@ TICKERS = [ BAR_SEC = 20 # 봉 주기 (초) VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수 ATR_LOOKBACK = 28 # ATR 계산 봉 수 -VOL_MIN = 8.0 # 거래량 배수 임계값 +VOL_MIN = 4.0 # 거래량 배수 임계값 (완화 — LLM이 최종 판단) +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 @@ -114,10 +117,11 @@ def on_tick(ticker: str, price: float, volume: float) -> None: def finalize_bars() -> None: - """BAR_SEC마다 봉 확정 + 지정가 체결 확인.""" + """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] @@ -125,8 +129,13 @@ def finalize_bars() -> None: continue bars[ticker].append(b) cur_bar[ticker] = _new_bar(b['close'], 0, now) - check_and_enter(ticker) - # 봉 확정 후 지정가 체결 확인 (bar_lock 밖에서) + 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() @@ -156,69 +165,36 @@ def calc_atr(bar_list: list) -> float: return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0 -# ── 시그널 감지 ─────────────────────────────────────────────────────────────── -def check_and_enter(ticker: str) -> None: +# ── 시그널 감지 (완화 — 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 - if ticker in positions: - return - if len(positions) >= MAX_POS: - return + return None + if ticker in positions or ticker in pending_buys: + return None + if len(positions) + len(pending_buys) >= MAX_POS: + return None - b0, b1, b2 = bar_list[-3], bar_list[-2], bar_list[-1] + b = bar_list[-1] + if b['close'] <= b['open']: + return None - if not all(b['close'] > b['open'] for b in [b0, b1, b2]): - return - if not (b2['close'] > b1['close'] > b0['close']): - return + vr = calc_vr(bar_list, n - 1) + if vr < VOL_MIN: + return None - vr2 = calc_vr(bar_list, n - 1) - vr1 = calc_vr(bar_list, n - 2) - vr0 = calc_vr(bar_list, n - 3) - - if vr2 < VOL_MIN or not (vr2 > vr1 > vr0): - return - - atr_raw = calc_atr(bar_list) - entry_price = b2['close'] - - log.info(f"[시그널] {ticker} {entry_price:,.0f}원 vol {vr2:.1f}x") - tg( - f"🔔 시그널 {ticker}\n" - f"가격: {b0['close']:,.0f}→{b1['close']:,.0f}→{b2['close']:,.0f}\n" - f"볼륨: {vr0:.1f}x→{vr1:.1f}x→{vr2:.1f}x" - ) - enter_position(ticker, entry_price, atr_raw, [vr0, vr1, vr2]) + return { + 'ticker': ticker, + 'price': b['close'], + 'vol_ratio': vr, + 'bar_list': bar_list, + } # ── 주문 ────────────────────────────────────────────────────────────────────── -def do_buy(ticker: str) -> tuple: - """시장가 매수. Returns (qty, avg_price).""" - if SIM_MODE: - price = pyupbit.get_current_price(ticker) - qty = PER_POS * (1 - FEE) / price - log.info(f"[SIM 매수] {ticker} {PER_POS:,}원 → {qty:.6f}개 @ {price:,.0f}") - return qty, price - try: - order = upbit_client.buy_market_order(ticker, PER_POS) - if not order or 'error' in str(order): - log.error(f"매수 실패: {order}") - return None, None - uuid = order.get('uuid') - time.sleep(1.5) - qty = upbit_client.get_balance(ticker.split('-')[1]) - avg_price = _avg_price_from_order(uuid) if uuid else None - if not avg_price: - avg_price = pyupbit.get_current_price(ticker) - return (qty if qty and qty > 0 else None), avg_price - except Exception as e: - log.error(f"매수 오류 {ticker}: {e}") - return None, None - - def _round_price(price: float) -> float: """Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지).""" if price >= 2_000_000: unit = 1000 @@ -309,21 +285,100 @@ def do_sell_market(ticker: str, qty: float) -> Optional[float]: return None -# ── 포지션 관리 ─────────────────────────────────────────────────────────────── -positions: dict = {} +# ── 지정가 매수 (LLM 판단) ─────────────────────────────────────────────────── +pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio} -def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) -> None: - qty, actual_price = do_buy(ticker) - if qty is None: - log.warning(f"[진입 실패] {ticker}") +def process_signal(sig: dict) -> None: + """시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출.""" + ticker = sig['ticker'] + bar_list = sig['bar_list'] + cur_price = sig['price'] + vol_ratio = sig['vol_ratio'] + + log.info(f"[시그널] {ticker} {cur_price:,.0f}원 vol {vol_ratio:.1f}x → LLM 판단 요청") + tg(f"🔔 시그널 {ticker}\n가격: {cur_price:,.0f}원 볼륨: {vol_ratio:.1f}x\nLLM 판단 요청 중...") + + buy_price = get_entry_price( + ticker=ticker, + signal=sig, + bar_list=bar_list, + current_price=cur_price, + num_positions=len(positions), + max_positions=MAX_POS, + ) + + if buy_price is None: + tg(f"⏭️ 매수 스킵 {ticker}\nLLM이 매수 거절") return - entry_price = actual_price or entry_price + buy_price = _round_price(buy_price) + qty = PER_POS * (1 - FEE) / 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, + } + log.info(f"[지정가매수] {ticker} {buy_price:,.0f}원 수량: {qty:.6f}") + tg( + f"📥 지정가 매수 제출 {ticker}\n" + f"가격: {buy_price:,.0f}원 수량: {qty:.6f}\n" + f"볼륨: {vol_ratio:.1f}x\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 SIM_MODE: + # SIM: 최근 봉 저가가 매수 지정가 이하이면 체결 + bar_list = list(bars.get(ticker, [])) + if bar_list and bar_list[-1]['low'] <= pb['price']: + log.info(f"[SIM 매수체결] {ticker} {pb['price']:,.0f}원") + _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{pb['price']:,.0f}원 {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) + target = entry_price * (1 + lr) sell_uuid = submit_limit_sell(ticker, qty, target) positions[ticker] = { @@ -334,18 +389,22 @@ def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) -> 'stage': 0, 'sell_uuid': sell_uuid, 'sell_price': target, - 'llm_last_ts': None, # LLM 마지막 호출 시각 + 'llm_last_ts': None, + 'llm_active': False, } - log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vr[2]:.1f}x " - f"지정가 {tag} {target:,.0f}원") + log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vol_ratio:.1f}x 지정가 {tag} {target:,.0f}원") tg( - f"🟢 매수 {ticker}\n" + f"🟢 매수 체결 {ticker}\n" f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n" - f"지정가 매도 제출: {tag} {target:,.0f}원 (+{lr*100:.1f}%)\n" + f"지정가 매도: {tag} {target:,.0f}원 (+{lr*100:.1f}%)\n" f"{'[시뮬]' if SIM_MODE else '[실거래]'}" ) +# ── 포지션 관리 ─────────────────────────────────────────────────────────────── +positions: dict = {} + + def _advance_stage(ticker: str) -> None: """다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문.""" pos = positions[ticker]