From 526003c97971e751b0880a3dc1e9bebf6f44e3f1 Mon Sep 17 00:00:00 2001 From: joungmin Date: Thu, 5 Mar 2026 22:34:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8F=AC=EC=A7=80=EC=85=98=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=EC=B4=88=EA=B3=BC=20=EB=B0=A9=EC=A7=80=20+=20LLM?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VOL_MIN 4→8 복원 (시그널 빈도 과다) - process_signal: LLM 호출 전/후 포지션 한도 재확인 - check_pending_buys: 체결 시점 한도 초과면 즉시 취소 - LLM tool 중복 호출 방지 (같은 tool+args → 캐시 응답) - 모든 tool 호출 완료 시 tool 제거해 강제 텍스트 응답 - max_rounds 8→5 축소 - 재시작 시 Upbit 잔고 기반 포지션 자동 복구 - LLM 모델: google/gemini-2.5-flash로 전환 Co-Authored-By: Claude Opus 4.6 --- core/llm_advisor.py | 18 +++++++++--- daemons/tick_trader.py | 63 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/core/llm_advisor.py b/core/llm_advisor.py index 6c65e38..ed00a21 100644 --- a/core/llm_advisor.py +++ b/core/llm_advisor.py @@ -436,15 +436,19 @@ def _call_llm(prompt: str, ticker: str) -> Optional[dict]: } messages = [{'role': 'user', 'content': prompt}] + max_rounds = 5 + called_tools: set = set() # 중복 tool 호출 방지 try: - for _ in range(5): + for round_i in range(max_rounds): body = { 'model': model, 'max_tokens': 512, - 'tools': _TOOLS, 'messages': messages, 'response_format': {'type': 'json_object'}, } + # 마지막 라운드 또는 모든 tool 이미 호출 → tool 제거해 강제 텍스트 응답 + if round_i < max_rounds - 1 and len(called_tools) < len(_TOOLS): + body['tools'] = _TOOLS resp = _req.post( 'https://openrouter.ai/api/v1/chat/completions', headers=headers, json=body, timeout=30, @@ -461,8 +465,14 @@ def _call_llm(prompt: str, ticker: str) -> Optional[dict]: 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}) 호출') + call_key = f'{fn_name}:{json.dumps(fn_args, sort_keys=True)}' + if call_key in called_tools: + # 중복 호출 → 캐시된 결과 반환 + fn_result = f'(이미 조회한 데이터입니다. 위 결과를 참고하세요.)' + else: + fn_result = _execute_tool(fn_name, fn_args) + called_tools.add(call_key) + log.info(f'[LLM-Tool] {ticker} {fn_name} 호출') messages.append({ 'role': 'tool', 'tool_call_id': tc['id'], diff --git a/daemons/tick_trader.py b/daemons/tick_trader.py index 6c0a719..e50d0c4 100644 --- a/daemons/tick_trader.py +++ b/daemons/tick_trader.py @@ -43,7 +43,7 @@ TICKERS = [ BAR_SEC = 20 # 봉 주기 (초) VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수 ATR_LOOKBACK = 28 # ATR 계산 봉 수 -VOL_MIN = 4.0 # 거래량 배수 임계값 (완화 — LLM이 최종 판단) +VOL_MIN = 8.0 # 거래량 배수 임계값 BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초) MAX_POS = int(os.environ.get('MAX_POSITIONS', 3)) @@ -296,6 +296,11 @@ def process_signal(sig: dict) -> None: 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} {cur_price:,.0f}원 vol {vol_ratio:.1f}x → LLM 판단 요청") llm_result = get_entry_price( @@ -319,6 +324,11 @@ def process_signal(sig: dict) -> None: ) 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', '') @@ -359,13 +369,19 @@ def process_signal(sig: dict) -> None: 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: - # 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}원") @@ -616,6 +632,46 @@ def preload_bars() -> None: 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)) + 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 + # cascade ① 지정가 매도 즉시 제출 + _, _, lr, stag = CASCADE_STAGES[0] + target = avg * (1 + lr) + sell_uuid = submit_limit_sell(ticker, bal, target) + positions[ticker] = { + 'entry_price': avg, + 'entry_ts': datetime.now() - timedelta(seconds=LLM_MIN_ELAPSED), # LLM 즉시 활성 + 'running_peak': avg, + 'qty': bal, + 'stage': 0, + 'sell_uuid': sell_uuid, + 'sell_price': target, + 'llm_last_ts': None, + 'llm_active': False, + } + log.info(f"[복구] {ticker} 수량:{bal:.6f} 매수평균:{avg:,.0f}원") + tg(f"♻️ 포지션 복구 {ticker}\n매수평균: {avg:,.0f}원 수량: {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}) ===") @@ -636,6 +692,7 @@ def main(): ) preload_bars() + restore_positions() t = threading.Thread(target=finalize_bars, daemon=True) t.start()