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:
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user