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}]
|
messages = [{'role': 'user', 'content': prompt}]
|
||||||
|
|
||||||
|
max_rounds = 5
|
||||||
|
called_tools: set = set() # 중복 tool 호출 방지
|
||||||
try:
|
try:
|
||||||
for _ in range(5):
|
for round_i in range(max_rounds):
|
||||||
body = {
|
body = {
|
||||||
'model': model,
|
'model': model,
|
||||||
'max_tokens': 512,
|
'max_tokens': 512,
|
||||||
'tools': _TOOLS,
|
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'response_format': {'type': 'json_object'},
|
'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(
|
resp = _req.post(
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
'https://openrouter.ai/api/v1/chat/completions',
|
||||||
headers=headers, json=body, timeout=30,
|
headers=headers, json=body, timeout=30,
|
||||||
@@ -461,8 +465,14 @@ def _call_llm(prompt: str, ticker: str) -> Optional[dict]:
|
|||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
fn_name = tc['function']['name']
|
fn_name = tc['function']['name']
|
||||||
fn_args = json.loads(tc['function']['arguments'])
|
fn_args = json.loads(tc['function']['arguments'])
|
||||||
|
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)
|
fn_result = _execute_tool(fn_name, fn_args)
|
||||||
log.info(f'[LLM-Tool] {ticker} {fn_name}({fn_args}) 호출')
|
called_tools.add(call_key)
|
||||||
|
log.info(f'[LLM-Tool] {ticker} {fn_name} 호출')
|
||||||
messages.append({
|
messages.append({
|
||||||
'role': 'tool',
|
'role': 'tool',
|
||||||
'tool_call_id': tc['id'],
|
'tool_call_id': tc['id'],
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ TICKERS = [
|
|||||||
BAR_SEC = 20 # 봉 주기 (초)
|
BAR_SEC = 20 # 봉 주기 (초)
|
||||||
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
||||||
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
||||||
VOL_MIN = 4.0 # 거래량 배수 임계값 (완화 — LLM이 최종 판단)
|
VOL_MIN = 8.0 # 거래량 배수 임계값
|
||||||
BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초)
|
BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초)
|
||||||
|
|
||||||
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
||||||
@@ -296,6 +296,11 @@ def process_signal(sig: dict) -> None:
|
|||||||
cur_price = sig['price']
|
cur_price = sig['price']
|
||||||
vol_ratio = sig['vol_ratio']
|
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 판단 요청")
|
log.info(f"[시그널] {ticker} {cur_price:,.0f}원 vol {vol_ratio:.1f}x → LLM 판단 요청")
|
||||||
|
|
||||||
llm_result = get_entry_price(
|
llm_result = get_entry_price(
|
||||||
@@ -319,6 +324,11 @@ def process_signal(sig: dict) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# LLM 호출 후 포지션 수 재확인
|
||||||
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
|
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
buy_price = _round_price(llm_result['price'])
|
buy_price = _round_price(llm_result['price'])
|
||||||
confidence = llm_result.get('confidence', '?')
|
confidence = llm_result.get('confidence', '?')
|
||||||
reason = llm_result.get('reason', '')
|
reason = llm_result.get('reason', '')
|
||||||
@@ -359,13 +369,19 @@ def process_signal(sig: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def check_pending_buys() -> None:
|
def check_pending_buys() -> None:
|
||||||
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃 시 취소."""
|
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소."""
|
||||||
for ticker in list(pending_buys.keys()):
|
for ticker in list(pending_buys.keys()):
|
||||||
pb = pending_buys[ticker]
|
pb = pending_buys[ticker]
|
||||||
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
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:
|
if SIM_MODE:
|
||||||
# SIM: 최근 봉 저가가 매수 지정가 이하이면 체결
|
|
||||||
bar_list = list(bars.get(ticker, []))
|
bar_list = list(bars.get(ticker, []))
|
||||||
if bar_list and bar_list[-1]['low'] <= pb['price']:
|
if bar_list and bar_list[-1]['low'] <= pb['price']:
|
||||||
log.info(f"[SIM 매수체결] {ticker} {pb['price']:,.0f}원")
|
log.info(f"[SIM 매수체결] {ticker} {pb['price']:,.0f}원")
|
||||||
@@ -616,6 +632,46 @@ def preload_bars() -> None:
|
|||||||
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
|
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():
|
def main():
|
||||||
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
||||||
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
||||||
@@ -636,6 +692,7 @@ def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
preload_bars()
|
preload_bars()
|
||||||
|
restore_positions()
|
||||||
|
|
||||||
t = threading.Thread(target=finalize_bars, daemon=True)
|
t = threading.Thread(target=finalize_bars, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user