feat: LLM-driven buy decisions with limit orders + robust JSON parsing

- Add get_entry_price() for LLM buy decisions (BTC trend, trade history, context tools)
- Replace market buy with LLM-determined limit buy price
- Lower signal threshold (VOL_MIN 8→4) — LLM makes final buy/skip decision
- Restructure tick_trader: detect_signal() inside lock, LLM call outside
- Add pending_buys tracking with timeout cancellation
- Remove unused enter_position() and do_buy() functions
- Fix JSON parsing: extract JSON from mixed text responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-05 21:52:44 +09:00
parent 7f1921441b
commit 19a35e1009
2 changed files with 435 additions and 180 deletions

View File

@@ -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