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:
226
daemons/context_collector.py
Normal file
226
daemons/context_collector.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""종목 컨텍스트 수집 데몬.
|
||||
|
||||
1시간마다 각 종목의 평판 정보를 수집해 Oracle ticker_context 테이블에 저장.
|
||||
LLM 어드바이저가 매도 판단 시 참조.
|
||||
|
||||
수집 항목:
|
||||
- price_stats: 24h/7d 가격 변동률, 거래량 추이
|
||||
- news: SearXNG 웹 검색 뉴스 요약
|
||||
|
||||
실행:
|
||||
.venv/bin/python3 daemons/context_collector.py
|
||||
로그:
|
||||
/tmp/context_collector.log
|
||||
"""
|
||||
import sys, os, time, logging, json, requests
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||
|
||||
import pyupbit
|
||||
import oracledb
|
||||
|
||||
TICKERS = [
|
||||
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
|
||||
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
|
||||
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
|
||||
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
|
||||
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
|
||||
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
|
||||
]
|
||||
|
||||
COLLECT_INTERVAL = 3600 # 1시간
|
||||
SEARXNG_URL = 'https://searxng.cloud-handson.com/search'
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/tmp/context_collector.log'),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
]
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 코인명 매핑 (검색 키워드용)
|
||||
COIN_NAMES = {
|
||||
'KRW-BTC': 'Bitcoin', 'KRW-ETH': 'Ethereum', 'KRW-XRP': 'Ripple XRP',
|
||||
'KRW-SOL': 'Solana', 'KRW-DOGE': 'Dogecoin', 'KRW-ADA': 'Cardano',
|
||||
'KRW-SUI': 'SUI', 'KRW-NEAR': 'NEAR Protocol', 'KRW-KAVA': 'KAVA',
|
||||
'KRW-SXP': 'Solar SXP', 'KRW-AKT': 'Akash', 'KRW-SONIC': 'Sonic',
|
||||
'KRW-IP': 'Story IP', 'KRW-ORBS': 'ORBS', 'KRW-VIRTUAL': 'Virtuals Protocol',
|
||||
'KRW-BARD': 'BARD', 'KRW-XPL': 'XPL', 'KRW-KITE': 'KITE',
|
||||
'KRW-ENSO': 'ENSO', 'KRW-0G': '0G', 'KRW-MANTRA': 'MANTRA OM',
|
||||
'KRW-EDGE': 'EDGE', 'KRW-CFG': 'Centrifuge', 'KRW-ARDR': 'Ardor',
|
||||
'KRW-SIGN': 'SIGN', 'KRW-AZTEC': 'AZTEC', 'KRW-ATH': 'Aethir',
|
||||
'KRW-HOLO': 'Holochain', 'KRW-BREV': 'BREV', 'KRW-SHIB': 'Shiba Inu',
|
||||
}
|
||||
|
||||
|
||||
def get_conn():
|
||||
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 upsert_context(conn, ticker: str, context_type: str, content: str):
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""MERGE INTO ticker_context tc
|
||||
USING (SELECT :t AS ticker, :ct AS context_type FROM dual) s
|
||||
ON (tc.ticker = s.ticker AND tc.context_type = s.context_type)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET content = :c, updated_at = SYSTIMESTAMP
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ticker, context_type, content)
|
||||
VALUES (:t, :ct, :c)""",
|
||||
{'t': ticker, 'ct': context_type, 'c': content},
|
||||
)
|
||||
|
||||
|
||||
def collect_price_stats(conn):
|
||||
"""각 종목의 24h/7d 가격 변동률 + 거래량 추이를 수집."""
|
||||
log.info("[price_stats] 수집 시작")
|
||||
count = 0
|
||||
for ticker in TICKERS:
|
||||
try:
|
||||
# 일봉 7개 (7일치)
|
||||
df_day = pyupbit.get_ohlcv(ticker, interval='day', count=8)
|
||||
if df_day is None or len(df_day) < 2:
|
||||
continue
|
||||
|
||||
today = df_day.iloc[-1]
|
||||
yesterday = df_day.iloc[-2]
|
||||
|
||||
# 24h 변동률
|
||||
chg_24h = (today['close'] - yesterday['close']) / yesterday['close'] * 100
|
||||
|
||||
# 7d 변동률
|
||||
if len(df_day) >= 8:
|
||||
week_ago = df_day.iloc[-8]
|
||||
chg_7d = (today['close'] - week_ago['close']) / week_ago['close'] * 100
|
||||
else:
|
||||
chg_7d = None
|
||||
|
||||
# 거래량 추이 (최근 3일 vs 이전 3일)
|
||||
if len(df_day) >= 7:
|
||||
recent_vol = df_day['volume'].iloc[-3:].mean()
|
||||
prev_vol = df_day['volume'].iloc[-6:-3].mean()
|
||||
vol_trend = recent_vol / prev_vol if prev_vol > 0 else 1.0
|
||||
else:
|
||||
vol_trend = None
|
||||
|
||||
# 1시간봉 최근 24개 (24시간)
|
||||
df_h1 = pyupbit.get_ohlcv(ticker, interval='minute60', count=24)
|
||||
h1_high = h1_low = None
|
||||
if df_h1 is not None and not df_h1.empty:
|
||||
h1_high = float(df_h1['high'].max())
|
||||
h1_low = float(df_h1['low'].min())
|
||||
|
||||
stats = {
|
||||
'price': float(today['close']),
|
||||
'chg_24h_pct': round(chg_24h, 2),
|
||||
'chg_7d_pct': round(chg_7d, 2) if chg_7d is not None else None,
|
||||
'vol_today': float(today['volume']),
|
||||
'vol_trend_3d': round(vol_trend, 2) if vol_trend is not None else None,
|
||||
'h24_high': h1_high,
|
||||
'h24_low': h1_low,
|
||||
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
}
|
||||
upsert_context(conn, ticker, 'price_stats', json.dumps(stats, ensure_ascii=False))
|
||||
count += 1
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
log.warning(f"[price_stats] {ticker} 오류: {e}")
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[price_stats] 완료 {count}/{len(TICKERS)} 종목")
|
||||
|
||||
|
||||
def search_news(coin_name: str, max_results: int = 5) -> list[dict]:
|
||||
"""SearXNG로 코인 뉴스 검색."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
SEARXNG_URL,
|
||||
params={
|
||||
'q': f'{coin_name} crypto news',
|
||||
'format': 'json',
|
||||
'categories': 'news',
|
||||
'language': 'ko-KR',
|
||||
'time_range': 'week',
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results = []
|
||||
for r in data.get('results', [])[:max_results]:
|
||||
results.append({
|
||||
'title': r.get('title', ''),
|
||||
'url': r.get('url', ''),
|
||||
'content': r.get('content', '')[:200],
|
||||
'date': r.get('publishedDate', ''),
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
log.warning(f"[news] {coin_name} 검색 오류: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def collect_news(conn):
|
||||
"""각 종목의 최근 뉴스를 수집."""
|
||||
log.info("[news] 수집 시작")
|
||||
count = 0
|
||||
for ticker in TICKERS:
|
||||
coin_name = COIN_NAMES.get(ticker, ticker.split('-')[1])
|
||||
try:
|
||||
articles = search_news(coin_name)
|
||||
if not articles:
|
||||
continue
|
||||
|
||||
news_data = {
|
||||
'coin': coin_name,
|
||||
'articles': articles,
|
||||
'updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
}
|
||||
upsert_context(conn, ticker, 'news', json.dumps(news_data, ensure_ascii=False))
|
||||
count += 1
|
||||
time.sleep(1) # 검색 API rate limit 배려
|
||||
except Exception as e:
|
||||
log.warning(f"[news] {ticker} 오류: {e}")
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[news] 완료 {count}/{len(TICKERS)} 종목")
|
||||
|
||||
|
||||
def main():
|
||||
log.info("=== context_collector 시작 (1시간 간격) ===")
|
||||
log.info(f"대상: {len(TICKERS)}개 종목")
|
||||
conn = get_conn()
|
||||
|
||||
while True:
|
||||
t0 = time.time()
|
||||
try:
|
||||
collect_price_stats(conn)
|
||||
collect_news(conn)
|
||||
except oracledb.DatabaseError as e:
|
||||
log.error(f"DB 오류: {e} — 재연결")
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
conn = get_conn()
|
||||
except Exception as e:
|
||||
log.error(f"오류: {e}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
log.info(f"[완료] {elapsed:.0f}초 소요, {COLLECT_INTERVAL}초 후 다음 수집")
|
||||
time.sleep(max(60, COLLECT_INTERVAL - elapsed))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user