Files
upbit-trader/core/llm_advisor.py
joungmin 7f1921441b feat: OpenRouter LLM 매도 어드바이저 + 종목 컨텍스트 수집 데몬
- llm_advisor: Anthropic → OpenRouter API 전환 (claude-haiku-4.5)
- llm_advisor: get_ticker_context DB tool 추가 (24h/7d 가격, 뉴스)
- llm_advisor: 구조화 JSON 응답 (confidence, reason, market_status, watch_needed)
- llm_advisor: LLM primary + cascade fallback (llm_active 플래그)
- llm_advisor: SQL bind variable 버그 수정 (INTERVAL → NUMTODSINTERVAL)
- tick_collector: backtest_ohlcv 1분봉 실시간 갱신 추가 (60초 주기)
- context_collector: 신규 데몬 — 1시간마다 price_stats + SearXNG 뉴스 수집
- ecosystem: tick-collector, tick-trader, context-collector PM2 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:02 +09:00

440 lines
17 KiB
Python

"""OpenRouter LLM 기반 매도 목표가 어드바이저.
1분 주기로 보유 포지션의 OHLCV 흐름을 분석해 최적 지정가 매도 목표가를 반환.
LLM이 주(primary), 기존 cascade 규칙이 fallback.
프롬프트에 포함되는 시장 데이터:
- 오늘 일봉 (고가/저가/거래량)
- 최근 4시간 1시간봉 (가격/볼륨 흐름)
- 최근 20봉 20초봉 (단기 패턴)
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 (현재 주문 유지) 또는 오류
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Optional
log = logging.getLogger(__name__)
# 프롬프트에 포함할 봉 수
INPUT_BARS = 20
# ── Oracle DB Tools ──────────────────────────────────────────────────────────
def _get_conn():
"""Oracle ADB 연결 (.env 기반)."""
import oracledb
kwargs = dict(
user = os.environ['ORACLE_USER'],
password = os.environ['ORACLE_PASSWORD'],
dsn = os.environ['ORACLE_DSN'],
)
if w := os.environ.get('ORACLE_WALLET'):
kwargs['config_dir'] = w
return oracledb.connect(**kwargs)
def _tool_get_price_ticks(ticker: str, minutes: int = 10) -> str:
"""Oracle price_tick 테이블에서 최근 N분 가격 틱 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, price
FROM price_tick
WHERE ticker = :t
AND ts >= SYSTIMESTAMP - NUMTODSINTERVAL(:m, 'MINUTE')
ORDER BY ts DESC
FETCH FIRST 100 ROWS ONLY""",
{'t': ticker, 'm': minutes},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 최근 {minutes}분 틱 데이터 없음"
lines = [f" {r[0].strftime('%H:%M:%S')} {float(r[1]):>12,.2f}" for r in rows]
return f"{ticker} 최근 {minutes}분 가격 틱 ({len(rows)}건):\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
def _tool_get_ohlcv(ticker: str, limit: int = 30) -> str:
"""Oracle backtest_ohlcv 1분봉 최근 N개 조회 (지지/저항 파악용)."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"""SELECT ts, open_p, high_p, low_p, close_p, volume_p
FROM backtest_ohlcv
WHERE ticker = :t
AND interval_cd = 'minute1'
ORDER BY ts DESC
FETCH FIRST :n ROWS ONLY""",
{'t': ticker, 'n': limit},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 1분봉 데이터 없음"
lines = [
f" {r[0].strftime('%H:%M')} O{float(r[1]):>10,.0f} H{float(r[2]):>10,.0f}"
f" L{float(r[3]):>10,.0f} C{float(r[4]):>10,.0f} V{float(r[5]):.0f}"
for r in reversed(rows)
]
return f"{ticker} 1분봉 최근 {len(rows)}개:\n" + "\n".join(lines)
except Exception as e:
return f"DB 오류: {e}"
# ── 종목 컨텍스트 조회 ────────────────────────────────────────────────────────
def _tool_get_context(ticker: str) -> str:
"""ticker_context 테이블에서 종목의 가격 통계 + 뉴스 조회."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute(
"SELECT context_type, content FROM ticker_context WHERE ticker = :t",
{'t': ticker},
)
rows = cur.fetchall()
conn.close()
if not rows:
return f"{ticker} 컨텍스트 데이터 없음"
parts = []
for ctx_type, content in rows:
parts.append(f"[{ctx_type}]\n{content}")
return f"{ticker} 종목 컨텍스트:\n" + "\n\n".join(parts)
except Exception as e:
return f"DB 오류: {e}"
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
_TOOLS = [
{
'type': 'function',
'function': {
'name': 'get_price_ticks',
'description': (
'Oracle DB에서 특정 종목의 최근 N분간 가격 틱 데이터를 조회합니다. '
'단기 가격 흐름과 지지/저항 수준 파악에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'minutes': {'type': 'integer', 'description': '최근 N분 데이터 (기본 10)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ohlcv',
'description': (
'Oracle DB에서 특정 종목의 1분봉 OHLCV 데이터를 조회합니다. '
'지지선/저항선 파악과 추세 분석에 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
'limit': {'type': 'integer', 'description': '조회할 봉 수 (기본 30)'},
},
'required': ['ticker'],
},
},
},
{
'type': 'function',
'function': {
'name': 'get_ticker_context',
'description': (
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
'매도 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
),
'parameters': {
'type': 'object',
'properties': {
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
},
'required': ['ticker'],
},
},
},
]
def _execute_tool(tool_name: str, tool_input: dict) -> str:
"""LLM이 요청한 tool을 실행하고 결과를 반환."""
if tool_name == 'get_price_ticks':
return _tool_get_price_ticks(
ticker = tool_input['ticker'],
minutes = tool_input.get('minutes', 10),
)
if tool_name == 'get_ohlcv':
return _tool_get_ohlcv(
ticker = tool_input['ticker'],
limit = tool_input.get('limit', 30),
)
if tool_name == 'get_ticker_context':
return _tool_get_context(ticker=tool_input['ticker'])
return f'알 수 없는 tool: {tool_name}'
# ── 시장 컨텍스트 수집 ───────────────────────────────────────────────────────
def _get_market_context(ticker: str) -> str:
"""오늘 일봉 요약 + 최근 4시간 1시간봉을 LLM 프롬프트용 텍스트로 반환.
pyupbit API 호출 실패 시 빈 문자열 반환 (graceful degradation).
"""
try:
import pyupbit
lines: list[str] = []
# 오늘 일봉 (오늘 + 전일 2개)
day_df = pyupbit.get_ohlcv(ticker, interval='day', count=2)
if day_df is not None and not day_df.empty:
today = day_df.iloc[-1]
prev = day_df.iloc[-2] if len(day_df) > 1 else None
vol_note = ''
if prev is not None and prev['volume'] > 0:
vol_note = f' (전일 대비 {today["volume"] / prev["volume"]:.1f}x)'
lines.append('[오늘 일봉]')
lines.append(f' 고가 {today["high"]:>12,.0f}원 저가 {today["low"]:>12,.0f}')
lines.append(f' 시가 {today["open"]:>12,.0f}원 거래량 {today["volume"]:,.0f}{vol_note}')
# 최근 4시간 1시간봉
h1_df = pyupbit.get_ohlcv(ticker, interval='minute60', count=4)
if h1_df is not None and not h1_df.empty:
lines.append(f'[최근 {len(h1_df)}시간봉]')
for ts, row in h1_df.iterrows():
lines.append(
f' {ts.strftime("%H:%M")} '
f'{row["high"]:>10,.0f}{row["low"]:>10,.0f} '
f'{row["close"]:>10,.0f} 거래량{row["volume"]:,.0f}'
)
return '\n'.join(lines)
except Exception as e:
log.debug(f'[LLM] 시장 컨텍스트 조회 실패: {e}')
return ''
# ── 프롬프트 빌더 ─────────────────────────────────────────────────────────────
def _describe_bars(bar_list: list[dict], current_price: float) -> str:
"""봉 데이터를 LLM이 읽기 쉬운 텍스트로 변환."""
recent = bar_list[-INPUT_BARS:] if len(bar_list) >= INPUT_BARS else bar_list
if not recent:
return '봉 데이터 없음'
lines = []
for b in recent:
ts_str = b['ts'].strftime('%H:%M:%S') if isinstance(b.get('ts'), datetime) else ''
lines.append(
f' {ts_str} 종가{b["close"]:>10,.0f} 고가{b["high"]:>10,.0f}'
f' 저가{b["low"]:>10,.0f}'
)
highs5 = [b['high'] for b in recent[-5:]]
closes5 = [b['close'] for b in recent[-5:]]
period_high = max(b['high'] for b in recent)
from_peak = (current_price - period_high) / period_high * 100
trend_h = '하락▼' if highs5[-1] < highs5[0] else '상승▲' if highs5[-1] > highs5[0] else '횡보─'
trend_c = '하락▼' if closes5[-1] < closes5[0] else '상승▲' if closes5[-1] > closes5[0] else '횡보─'
summary = (
f'\n[패턴 요약]\n'
f'- 고가 추세: {trend_h} ({highs5[0]:,.0f}{highs5[-1]:,.0f})\n'
f'- 종가 추세: {trend_c} ({closes5[0]:,.0f}{closes5[-1]:,.0f})\n'
f'- 구간 최고가: {period_high:,.0f}원 현재가 대비: {from_peak:+.2f}%'
)
return '\n'.join(lines) + summary
def _build_prompt(
ticker: str,
entry_price: float,
current_price: float,
elapsed_min: float,
current_target: float,
bar_desc: str,
market_context: str = '',
) -> str:
pnl_pct = (current_price - entry_price) / entry_price * 100
target_gap = (current_target - current_price) / current_price * 100
market_section = f'\n{market_context}\n' if market_context else ''
return f"""당신은 암호화폐 단기 트레이더입니다.
아래 포지션과 가격 흐름을 분석해 **지정가 매도 목표가**를 판단하세요.
필요하면 제공된 DB tool을 호출해 추가 데이터를 조회하세요.
특히 get_ticker_context로 종목의 24h/7d 가격 변동, 거래량 추이, 최근 뉴스를 확인하세요.
[현재 포지션]
종목 : {ticker}
진입가 : {entry_price:,.0f}
현재가 : {current_price:,.0f}원 ({pnl_pct:+.2f}%)
보유시간: {elapsed_min:.0f}
현재 지정가: {current_target:,.0f}원 (현재가 대비 {target_gap:+.2f}%, 미체결)
{market_section}
[최근 {INPUT_BARS}봉 (20초봉)]
{bar_desc}
[운용 정책 참고 — 최종 판단은 당신이 결정]
- 단기 거래량 가속 신호 진입 후 cascade 청산 전략 (지정가 단계적 조정)
- 수익 목표: 진입가 대비 +0.5% ~ +2% 구간
- 체결 가능성이 낮으면 현실적인 목표가로 조정 권장
- 상승 여력이 있으면 hold 권장
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
매도 지정가를 설정할 경우:
{{"action": "sell", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": false}}
현재 주문을 유지할 경우:
{{"action": "hold", "reason": "유지 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락", "watch_needed": true/false}}
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
# ── 메인 함수 ─────────────────────────────────────────────────────────────────
def get_exit_price(
ticker: str,
pos: dict,
bar_list: list[dict],
current_price: float,
) -> Optional[float]:
"""LLM에게 매도 목표가를 물어본다. (DB tool 사용 가능)
Args:
ticker: 종목 코드 (예: 'KRW-XRP')
pos: positions[ticker] 딕셔너리
bar_list: list(bars[ticker]) — 최신봉이 마지막
current_price: 현재 틱 가격
Returns:
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
None → hold 또는 오류
"""
import requests as _req
api_key = os.environ.get('OPENROUTER_API_KEY', '')
if not api_key:
log.debug('[LLM] OPENROUTER_API_KEY 없음 → cascade fallback')
return None
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4-5-20251001')
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')
return None