fix: 포지션 한도 초과 방지 + LLM 호출 최적화

- 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 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-05 22:34:35 +09:00
parent 4f9e2c44c7
commit 526003c979
2 changed files with 74 additions and 7 deletions

View File

@@ -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"♻️ <b>포지션 복구</b> {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()