import httpx import asyncio import re import time from datetime import datetime from typing import Dict, Optional from sqlalchemy.orm import Session from sqlalchemy import update # 프로젝트 구조에 따라 .models 또는 models에서 Asset을 가져옵니다. try: from .models import Asset except ImportError: from models import Asset class DataFetcher: def __init__(self): # 인베스팅닷컴은 헤더가 없으면 403 에러를 뱉습니다. 브라우저와 동일하게 설정합니다. self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } self.daily_closing_prices = {} async def fetch_google_finance(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]: """인베스팅보다 빠르고 야후보다 안정적인 구글 파이낸스 우회""" try: # 구글 파이낸스 환율/지수 URL symbol = "USD-KRW" if asset_code == "USD/KRW" else "INDEXDXY:CURRENCY" if asset_code == "USD/DXY" else None if not symbol: return None url = f"https://www.google.com/finance/quote/{symbol}" # 구글은 헤더만 있으면 응답 속도가 정말 빠릅니다. res = await client.get(url, timeout=5) if res.status_code != 200: return None # 구글 특유의 가격 클래스 추출 (정규식) m = re.search(r'data-last-price="([\d,.]+)"', res.text) if m: return float(m.group(1).replace(",", "")) except Exception as e: print(f"⚠️ Google Finance 에러 ({asset_code}): {e}") return None async def fetch_investing_com(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]: """인베스팅닷컴 비동기 수집 (USD/KRW 포함 전용)""" try: # 자산별 URL 매핑 if asset_code == "USD/DXY": url = "https://www.investing.com/indices/usdollar" # elif asset_code == "USD/KRW": # url = "https://kr.investing.com/currencies/usd-krw" else: url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}" # 인베스팅은 쿠키와 리다이렉트가 중요하므로 follow_redirects 사용 response = await client.get(url, timeout=15, follow_redirects=True) if response.status_code != 200: print(f"⚠️ Investing 응답 에러 ({asset_code}): {response.status_code}") return None html = response.text # 인베스팅닷컴의 다양한 HTML 구조에 대응하는 정규식 (우선순위 순) patterns = [ r'data-test="instrument-price-last">([\d,.]+)<', # 최신 메인 패턴 r'last_last">([\d,.]+)<', # 구형/세부 페이지 패턴 r'instrument-price-last">([\d,.]+)<', # 클래식 패턴 r'class="[^"]*text-2xl[^"]*">([\d,.]+)<' # 비상용 패턴 ] for pattern in patterns: p = re.search(pattern, html) if p: val_str = p.group(1).replace(',', '') return float(val_str) print(f"⚠️ Investing 패턴 매칭 실패 ({asset_code})") except Exception as e: print(f"⚠️ Investing 시스템 에러 ({asset_code}): {e}") return None async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]: """바이낸스 BTC/USDT""" try: url = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSD" res = await client.get(url, timeout=5) return float(res.json()["price"]) except: return None async def fetch_upbit(self, client: httpx.AsyncClient) -> Optional[float]: """업비트 BTC/KRW""" try: url = "https://api.upbit.com/v1/ticker?markets=KRW-BTC" res = await client.get(url, timeout=5) return float(res.json()[0]["trade_price"]) except: return None async def fetch_krx_gold(self, client: httpx.AsyncClient) -> Optional[float]: """네이버 금 시세 (국내)""" try: url = "https://m.stock.naver.com/marketindex/metals/M04020000" res = await client.get(url, timeout=5) m = re.search(r'\"closePrice\":\"([\d,.]+)\"', res.text) if m: return float(m.group(1).replace(",", "")) except: return None async def update_closing_prices(self, db: Session): """매일 아침 기준가를 스냅샷 찍어 메모리에 저장""" results = await self.update_realtime_prices(db) for symbol, price in results.items(): if price: self.daily_closing_prices[symbol] = price print(f"📌 [기준가 업데이트] 완료: {self.daily_closing_prices}") async def update_realtime_prices(self, db: Session) -> Dict: """[핵심] 비동기 수집 후 DB 즉시 업데이트""" start_time = time.time() async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client: # 1. 병렬 수집 태스크 정의 (USD/KRW도 인베스팅닷컴 함수 사용) tasks = { "XAU/USD": self.fetch_investing_com(client, "XAU/USD"), "XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"), "XAU/GBP": self.fetch_investing_com(client, "XAU/GBP"), "USD/DXY": self.fetch_investing_com(client, "USD/DXY"), "USD/KRW": self.fetch_google_finance(client, "USD/KRW"), "BTC/USD": self.fetch_binance(client), "BTC/KRW": self.fetch_upbit(client), "KRX/GLD": self.fetch_krx_gold(client), } keys = list(tasks.keys()) values = await asyncio.gather(*tasks.values(), return_exceptions=True) # 결과 가공 raw_results = {} for i, val in enumerate(values): symbol = keys[i] if isinstance(val, (int, float)): raw_results[symbol] = val else: raw_results[symbol] = None print(f"❌ {symbol} 수집 실패: {val}") # [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑 if raw_results.get("XAU/USD") and raw_results.get("USD/KRW"): # (현재가) 국제 금 시세 실시간 계산 raw_results["XAU/KRW"] = round((raw_results["XAU/USD"] / 31.1034768) * raw_results["USD/KRW"], 0) # [추가] XAU/KRW의 '전일종가' 필드에 'KRX/GLD 현재가'를 강제로 주입 # 이렇게 하면 화면에서 (국제계산가 - 국내현물가)가 실시간 변동액으로 표시됨 if raw_results.get("KRX/GLD"): from app.models import UserAsset, Asset asset_xau = db.query(Asset).filter(Asset.symbol == "XAU/KRW").first() if asset_xau: db.execute( update(UserAsset) .where(UserAsset.asset_id == asset_xau.id) .values(previous_close=raw_results["KRX/GLD"]) ) # 2) [핵심 추가] 메모리에 저장된 기준가도 실시간 KRX 가격으로 갱신! # 그래야 밑에 있는 '3. DB 업데이트 수행' 루프에서 state(up/down)가 실시간으로 계산됩니다. self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"] else: raw_results["XAU/KRW"] = None # 3. DB 업데이트 수행 for symbol, price in raw_results.items(): if price is not None: state = "stable" if symbol in self.daily_closing_prices: closing = self.daily_closing_prices[symbol] if price > closing: state = "up" elif price < closing: state = "down" # SQL 실행 db.execute( update(Asset) .where(Asset.symbol == symbol) .values( current_price=price, price_state=state, last_updated=datetime.now() ) ) db.commit() print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)") return raw_results # 싱글톤 인스턴스 생성 fetcher = DataFetcher()