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>
This commit is contained in:
439
core/llm_advisor.py
Normal file
439
core/llm_advisor.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user