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:
joungmin
2026-03-05 21:39:02 +09:00
parent ab5c963803
commit 7f1921441b
7 changed files with 1939 additions and 0 deletions

439
core/llm_advisor.py Normal file
View 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