diff --git a/asset_pilot_docker/app/fetcher.py b/asset_pilot_docker/app/fetcher.py index 5dda910..3bda50a 100644 --- a/asset_pilot_docker/app/fetcher.py +++ b/asset_pilot_docker/app/fetcher.py @@ -7,7 +7,7 @@ from typing import Dict, Optional from sqlalchemy.orm import Session from sqlalchemy import update -# 프로젝트 구조에 따라 .models 또는 models에서 Asset을 가져옵니다. +# [중요] 순환 참조 방지를 위해 상단에서는 Asset만 가져옵니다. try: from .models import Asset except ImportError: @@ -15,7 +15,7 @@ except ImportError: 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', @@ -26,18 +26,15 @@ class DataFetcher: 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(",", "")) @@ -46,38 +43,30 @@ class DataFetcher: 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,.]+)<' # 비상용 패턴 + 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) + return float(p.group(1).replace(',', '')) print(f"⚠️ Investing 패턴 매칭 실패 ({asset_code})") except Exception as e: @@ -85,7 +74,7 @@ class DataFetcher: return None async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]: - """바이낸스 BTC/USDT""" + """바이낸스 BTC/USD""" try: url = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSD" res = await client.get(url, timeout=5) @@ -111,7 +100,7 @@ class DataFetcher: 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: @@ -119,11 +108,11 @@ class DataFetcher: print(f"📌 [기준가 업데이트] 완료: {self.daily_closing_prices}") async def update_realtime_prices(self, db: Session) -> Dict: - """[핵심] 비동기 수집 후 DB 즉시 업데이트""" + """[핵심] 비동기 수집 후 DB 즉시 업데이트 및 특수 로직 처리""" start_time = time.time() async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client: - # 1. 병렬 수집 태스크 정의 (USD/KRW도 인베스팅닷컴 함수 사용) + # 1. 병렬 수집 tasks = { "XAU/USD": self.fetch_investing_com(client, "XAU/USD"), "XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"), @@ -138,63 +127,55 @@ class DataFetcher: 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}") + raw_results = {keys[i]: (v if isinstance(v, (int, float)) else None) for i, v in enumerate(values)} - # [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑 + # 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" + try: + # [A] 기본 Asset 테이블 업데이트 + 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.execute( + update(Asset) + .where(Asset.symbol == symbol) + .values(current_price=price, price_state=state, last_updated=datetime.now()) ) - ) - - db.commit() + + # [B] XAU/KRW 프리미엄용 특수 로직 (KRX 가격을 전일종가로 세팅) + if raw_results.get("KRX/GLD"): + try: + from .models import UserAsset, Asset as ModelAsset # 로컬 임포트 유지 + except ImportError: + from models import UserAsset, Asset as ModelAsset + + target_asset = db.query(ModelAsset).filter(ModelAsset.symbol == "XAU/KRW").first() + if target_asset: + db.execute( + update(UserAsset) + .where(UserAsset.asset_id == target_asset.id) + .values(previous_close=raw_results["KRX/GLD"]) + ) + # 메모리 기준가도 함께 갱신하여 UI 상태 화살표 유지 + self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"] + + db.commit() + except Exception as e: + db.rollback() + print(f"❌ DB 저장 중 오류 발생: {e}") print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)") return raw_results -# 싱글톤 인스턴스 생성 +# 싱글톤 인스턴스 fetcher = DataFetcher() \ No newline at end of file diff --git a/asset_pilot_docker/main.py b/asset_pilot_docker/main.py index ef9b82b..f410c90 100644 --- a/asset_pilot_docker/main.py +++ b/asset_pilot_docker/main.py @@ -4,13 +4,15 @@ import asyncio import httpx from datetime import datetime, timedelta from typing import Dict +from contextlib import asynccontextmanager # lifespan용 추가 + from fastapi import FastAPI, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -# [변경] 비동기용 스케줄러로 교체 from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy.orm import Session +from sqlalchemy import update, or_ from pydantic import BaseModel from dotenv import load_dotenv @@ -24,36 +26,6 @@ load_dotenv() # 데이터베이스 테이블 생성 Base.metadata.create_all(bind=engine) -app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.2.0") -# 1. 현재 main.py 파일의 절대 경로를 가져옵니다. -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - -# 2. Static 파일 경로 설정 (절대 경로 사용) -static_path = os.path.join(BASE_DIR, "static") -if os.path.exists(static_path): - app.mount("/static", StaticFiles(directory=static_path), name="static") - print(f"✅ Static 마운트 성공: {static_path}") -else: - print(f"❌ Static 폴더를 찾을 수 없습니다: {static_path}") - -# 3. 템플릿 설정 (절대 경로 사용) -templates_path = os.path.join(BASE_DIR, "templates") -templates = Jinja2Templates(directory=templates_path) - -# [변경] 비동기 스케줄러 설정 -scheduler = AsyncIOScheduler(timezone="Asia/Seoul") - -# 전역 상태 관리 -connected_clients = 0 -clients_lock = asyncio.Lock() -last_alert_time = {} - -# [신규] 시스템 상태 모니터링 변수 (Heartbeat) -system_status = { - "last_fetch_time": None, - "status": "initializing" -} - # ==================== Pydantic 모델 ==================== class UserAssetUpdate(BaseModel): symbol: str @@ -64,6 +36,19 @@ class UserAssetUpdate(BaseModel): class AlertSettingUpdate(BaseModel): settings: Dict +# ==================== 전역 변수 및 설정 ==================== +# [변경] 비동기 스케줄러 설정 +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +connected_clients = 0 +clients_lock = asyncio.Lock() +last_alert_time = {} + +system_status = { + "last_fetch_time": None, + "status": "initializing" +} + # ==================== 유틸리티 함수 ==================== async def send_telegram_msg_async(text: str): """비동기 방식으로 텔레그램 메시지 전송""" @@ -80,7 +65,6 @@ async def send_telegram_msg_async(text: str): if resp.status_code != 200: print(f"❌ 텔레그램 실패: {resp.text}") except Exception as e: print(f"❌ 텔레그램 오류: {e}") -# ==================== DB 초기화 ==================== def init_db_data(): db = SessionLocal() try: @@ -113,7 +97,7 @@ def init_db_data(): finally: db.close() -# ==================== 백그라운드 태스크 (Watchdog & 알림 통합) ==================== +# ==================== 백그라운드 태스크 ==================== async def background_fetch(): """비동기 수집 루프: DB 업데이트 + Heartbeat + 알림""" while True: @@ -123,14 +107,10 @@ async def background_fetch(): db = SessionLocal() try: - # 1. 수집 및 DB 업데이트 current_data = await fetcher.update_realtime_prices(db) - - # [성공] Heartbeat 기록 system_status["last_fetch_time"] = datetime.now() system_status["status"] = "healthy" - # 2. 알림 로직 settings_raw = db.query(AlertSetting).all() sets = {s.setting_key: json.loads(s.setting_value) for s in settings_raw} user_assets = db.query(Asset, UserAsset).join(UserAsset).all() @@ -145,7 +125,6 @@ async def background_fetch(): prev_c = float(ua.previous_close) if ua.previous_close else 0 avg_p = float(ua.average_price) if ua.average_price else 0 - # 급등락 체크 if sets.get("급등락_감지") and prev_c > 0: change = ((price - prev_c) / prev_c) * 100 if abs(change) >= float(sets.get("급등락_임계값", 3.0)): @@ -154,7 +133,6 @@ async def background_fetch(): await send_telegram_msg_async(f"[{icon}] {symbol}\n현재가: {price:,.2f}\n변동률: {change:+.2f}%") last_alert_time[f"{symbol}_vol"] = now_ts - # 수익률 체크 if sets.get("목표수익률_감지") and avg_p > 0: profit = ((price - avg_p) / avg_p) * 100 if profit >= float(sets.get("목표수익률", 10.0)): @@ -162,7 +140,6 @@ async def background_fetch(): await send_telegram_msg_async(f"💰 수익 목표달성! ({symbol})\n수익률: {profit:+.2f}%\n현재가: {price:,.2f}") last_alert_time[f"{symbol}_profit"] = now_ts - # 특정가격 감지 if sets.get("특정가격_감지"): if symbol == "KRX/GLD" and price >= float(sets.get("금_목표가격", 0)): if now_ts - last_alert_time.get("gold_hit", 0) > 43200: @@ -172,7 +149,6 @@ async def background_fetch(): if now_ts - last_alert_time.get("btc_hit", 0) > 43200: await send_telegram_msg_async(f"₿ BTC 목표가 돌파!\n현재가: {price:,.0f}원") last_alert_time["btc_hit"] = now_ts - finally: db.close() except Exception as e: @@ -181,28 +157,73 @@ async def background_fetch(): await asyncio.sleep(interval) -# ==================== 앱 생명주기 (AsyncIOScheduler 적용) ==================== -@app.on_event("startup") -async def startup_event(): +# ==================== [신규] Lifespan 핸들러 ==================== +@asynccontextmanager +async def lifespan(app: FastAPI): + # [Startup] 앱 시작 시 실행 init_db_data() - # [변경] 7시 10분 비동기 전용 스케줄러 작업 - async def daily_job(): - print(f"🌅 [기준가 업데이트 시작] {datetime.now()}") + # 서버 재시작 시 DB의 종가를 메모리로 복구 (fetcher에 해당 함수가 있다고 가정) + db = SessionLocal() + try: + # DB에서 기존 종가를 불러와 메모리 fetcher.daily_closing_prices 채우기 + assets = db.query(Asset, UserAsset).join(UserAsset).all() + for a, ua in assets: + if ua.previous_close: + fetcher.daily_closing_prices[a.symbol] = float(ua.previous_close) + finally: + db.close() + + # 1. 비트코인 전용 자정 스케줄러 (00:00:05) + async def btc_daily_job(): + print(f"🕛 [BTC 자정 스냅샷] {datetime.now()}") db = SessionLocal() try: - await fetcher.update_closing_prices(db) - finally: - db.close() + assets = db.query(Asset).filter(Asset.symbol.like("BTC/%")).all() + for asset in assets: + if asset.current_price: + db.execute(update(UserAsset).where(UserAsset.asset_id == asset.id).values(previous_close=asset.current_price)) + fetcher.daily_closing_prices[asset.symbol] = asset.current_price + db.commit() + finally: db.close() - scheduler.add_job(daily_job, 'cron', hour=7, minute=10, id='daily_snapshot') + # 2. 전통 자산용 아침 스케줄러 (07:10:00) + async def legacy_daily_job(): + print(f"🌅 [전통자산 아침 스냅샷] {datetime.now()}") + db = SessionLocal() + try: + assets = db.query(Asset).filter(~Asset.symbol.like("BTC/%")).all() + for asset in assets: + if asset.current_price: + db.execute(update(UserAsset).where(UserAsset.asset_id == asset.id).values(previous_close=asset.current_price)) + fetcher.daily_closing_prices[asset.symbol] = asset.current_price + db.commit() + finally: db.close() + + scheduler.add_job(btc_daily_job, 'cron', hour=0, minute=0, second=5, id='btc_snapshot') + scheduler.add_job(legacy_daily_job, 'cron', hour=7, minute=10, id='legacy_snapshot') scheduler.start() - asyncio.create_task(background_fetch()) + fetch_task = asyncio.create_task(background_fetch()) + + yield # 서버 운영 중 -@app.on_event("shutdown") -def stop_scheduler(): + # [Shutdown] 서버 종료 시 실행 scheduler.shutdown() + fetch_task.cancel() + print("👋 서버가 종료되었습니다.") + +# ==================== FastAPI 앱 선언 ==================== +app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.2.0", lifespan=lifespan) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +static_path = os.path.join(BASE_DIR, "static") +if os.path.exists(static_path): + app.mount("/static", StaticFiles(directory=static_path), name="static") + print(f"✅ Static 마운트 성공: {static_path}") + +templates_path = os.path.join(BASE_DIR, "templates") +templates = Jinja2Templates(directory=templates_path) # ==================== API 엔드포인트 ==================== @app.get("/", response_class=HTMLResponse) @@ -211,10 +232,7 @@ async def read_root(request: Request): @app.get("/api/prices") async def get_prices(db: Session = Depends(get_db)): - """[개선] 데이터 신선도 상태와 서버 시각을 포함하여 반환""" assets = db.query(Asset).all() - - # 지연 판별 (마지막 성공 후 60초 경과 시 stale) is_stale = False if system_status["last_fetch_time"]: if datetime.now() - system_status["last_fetch_time"] > timedelta(seconds=60): @@ -300,8 +318,4 @@ async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends( @app.get("/health") async def health_check(): - return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]} - -# if __name__ == "__main__": -# import uvicorn -# uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) \ No newline at end of file + return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]} \ No newline at end of file diff --git a/asset_pilot_docker/updateRecord.md b/asset_pilot_docker/updateRecord.md new file mode 100644 index 0000000..cead073 --- /dev/null +++ b/asset_pilot_docker/updateRecord.md @@ -0,0 +1,162 @@ +2026-02-14 (08:46) + +선임님, VSCode에서 멤버명(변수나 함수 이름)에 **가운데 줄(취소선, Strikethrough)**이 그어져 있는 건 **"Deprecated(사용 권장되지 않음)"**라는 뜻입니다. + +쉽게 말해 **"이 기능은 아직 작동은 하지만, 곧 없어질 예정이거나 더 좋은 다른 기능으로 대체되었으니 웬만하면 쓰지 마라"**고 경고를 보내는 겁니다. + +🧐 왜 가운데 줄이 생겼을까요? +라이브러리 업데이트: 선임님이 쓰시는 라이브러리(예: SQLAlchemy, FastAPI, Pydantic 등)가 버전업 되면서, 기존 방식을 버리고 새로운 방식을 권장할 때 발생합니다. + +보안/성능 이슈: 해당 멤버를 계속 쓰면 보안에 취약하거나 성능이 떨어질 수 있다고 판단될 때 개발자가 표시를 해둔 겁니다. + +🛠️ 어떻게 해결하나요? +가장 쉬운 확인 방법은 그 가운데 줄이 쳐진 이름 위에 마우스 커서를 가만히 올려보는 겁니다. + +그러면 "@deprecated: Use 'NewFunctionName' instead" 같은 가이드 팝업이 뜰 거예요. + +거기서 추천하는 새로운 멤버명으로 이름을 바꿔주기만 하면 줄이 사라지고 코드도 최신 트렌드에 맞게 정리가 됩니다. + +💡 우리 코드에서 주의할 점 +혹시 아까 제가 짜드린 코드나 선임님이 작성하신 코드 중에 on_event("startup") 부분에 줄이 가 있나요? +최신 FastAPI에서는 @app.on_event("startup") 대신 **lifespan**이라는 방식을 권장하기 때문에 취소선이 생길 수 있습니다. + +ython +from contextlib import asynccontextmanager + +# [최신 방식] 앱 실행 전후를 한 번에 관리하는 lifespan 함수 +@asynccontextmanager +async def lifespan(app: FastAPI): + # --- Startup (앱 시작 시 실행) --- + init_db_data() + + # 이원화 스케줄러 등록 + async def btc_daily_job(): + # ... (비트코인 자정 스냅샷 로직 동일) ... + pass + + async def legacy_daily_job(): + # ... (전통자산 아침 스냅샷 로직 동일) ... + pass + + scheduler.add_job(btc_daily_job, 'cron', hour=0, minute=0, second=5, id='btc_snapshot') + scheduler.add_job(legacy_daily_job, 'cron', hour=7, minute=10, id='legacy_snapshot') + scheduler.start() + + fetch_task = asyncio.create_task(background_fetch()) + + yield # 앱이 실행되는 동안 여기서 대기합니다. + + # --- Shutdown (앱 종료 시 실행) --- + scheduler.shutdown() + fetch_task.cancel() + print("👋 서버가 안전하게 종료되었습니다.") + +# FastAPI 선언 시 lifespan을 넣어줍니다. +app = FastAPI(title="Asset Pilot", lifespan=lifespan) +🧐 왜 이걸로 갈아타야 할까요? (취소선의 이유) +자원 관리: lifespan 방식은 yield를 기준으로 시작과 끝이 명확하게 한 곳에 모여 있어, 나중에 DB 커넥션이나 소켓을 닫을 때 실수를 안 하게 해줍니다. + +비동기 최적화: 이전 방식보다 비동기 태스크들의 생명주기를 더 정확하게 제어할 수 있습니다. + +VSCode 클린업: 이제 그 지긋지긋한 **가운데 줄(취소선)**이 사라지고, 선임님의 VSCode가 다시 깨끗해질 겁니다. + + +Gemini said +선임님, fetcher.py 소스 코드 잘 봤습니다. 코드를 뜯어보니 데이터 수집은 되는데 왜 현재가가 안 변했는지 확실한 범인이 잡혔습니다. + +범인은 바로 db.commit()의 위치와 XAU/KRW의 특수 로직 때문입니다. + +🕵️ fetcher.py의 문제점 분석 +1. XAU/KRW 프리미엄 로직의 '월권' (핵심 원인) +코드 중간에 보면 이런 로직이 있습니다: + +Python +# [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑 +if raw_results.get("KRX/GLD"): + # ... 중략 ... + db.execute(update(UserAsset).where(UserAsset.asset_id == asset_xau.id).values(previous_close=raw_results["KRX/GLD"])) +여기서 db.execute를 실행했는데, 이 작업이 완료되기도 전에 밑에서 다른 update(Asset) 루프가 돌고 있습니다. SQLAlchemy 세션 하나에서 여러 update를 비동기 루프와 섞어 쓸 때, 가끔 먼저 실행된 execute가 세션을 점유하거나 락(Lock)이 걸려 뒤의 업데이트가 씹히는 경우가 발생합니다. + +2. db.commit()이 너무 뒤에 있음 +현재 모든 자산의 가격을 루프로 돌면서 db.execute를 예약만 해두고, **맨 마지막에 한 번만 db.commit()**을 합니다. 만약 루프 도중 단 하나의 자산이라도 DB 제약 조건에 걸리거나 에러가 나면, 그전까지 업데이트한 모든 가격이 롤백되어 반영되지 않습니다. #### 3. fetch_google_finance의 정규식 오류 가능성 +현재 구글 파이낸스 정규식이 data-last-price="([\d,.]+)"로 되어 있는데, 최근 구글 파이낸스 HTML 구조가 바뀌어서 이 속성이 안 나타날 때가 있습니다. 만약 환율(USD/KRW) 값이 None이 되면, 이를 기반으로 계산하는 XAU/KRW도 줄줄이 사탕으로 업데이트가 안 됩니다. + +🛠️ 수정한 fetcher.py (핵심 구간) +선임님, 아래 부분을 특히 주의해서 봐주세요. 안정성을 위해 업데이트 방식을 더 직관적으로 고쳤습니다. + +Python + async def update_realtime_prices(self, db: Session) -> Dict: + start_time = time.time() + + async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client: + 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 = {keys[i]: (v if isinstance(v, (int, float)) else None) for i, v in enumerate(values)} + + # [개선] 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) + + # [개선] DB 업데이트 로직 분리 및 안정화 + try: + for symbol, price in raw_results.items(): + if price is not None: + # 1. 상태(Up/Down) 계산 + state = "stable" + closing = self.daily_closing_prices.get(symbol) + if closing: + if price > closing: state = "up" + elif price < closing: state = "down" + + # 2. Asset 테이블 업데이트 (현재가) + db.query(Asset).filter(Asset.symbol == symbol).update({ + "current_price": price, + "price_state": state, + "last_updated": datetime.now() + }) + + # 3. 특수 로직 (XAU/KRW 프리미엄 계산용 기준가 변경) + if raw_results.get("KRX/GLD"): + asset_xau = db.query(Asset).filter(Asset.symbol == "XAU/KRW").first() + if asset_xau: + # UserAsset 테이블의 previous_close를 KRX 현물가로 강제 동기화 + db.query(UserAsset).filter(UserAsset.asset_id == asset_xau.id).update({ + "previous_close": raw_results["KRX/GLD"] + }) + self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"] + + db.commit() # 모든 업데이트 완료 후 커밋 + + except Exception as e: + db.rollback() + print(f"❌ DB 업데이트 중 롤백 발생: {e}") + + print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)") + return raw_results +💡 선임님을 위한 조언 +XAU/KRW 프리미엄 수식: 지금 선임님 코드는 국제 시세와 국내 시세의 차이를 보여주기 위해 previous_close 값을 실시간으로 계속 덮어쓰고 계시네요. 이 방식은 등락률 계산 로직을 우회하는 아주 영리한 방법이지만, DB에 계속 쓰기 작업을 발생시킵니다. (오렌지파이 eMMC 수명에는 조금 가혹할 수 있지만, 지금 데이터 양으로는 큰 문제 없습니다!) + +구글 환율 체크: 만약 로그에 USD/KRW가 None으로 찍힌다면, 구글 파이낸스 정규식을 r'class="fxKb7e".*?>([\d,.]+)<' 같은 클래스 기반으로 바꿔야 할 수도 있습니다. + + + +🔍 빠진 게 없는지 대조 결과 +패턴 매칭: patterns 리스트에 4가지 정규식을 모두 유지했습니다. + +병렬 수집: asyncio.gather를 통해 모든 자산(8종)을 동시에 긁어옵니다. + +XAU/KRW 로직: 환율과 국제 금값을 곱해 원화 금값을 계산하고, 국내 현물가(KRX/GLD)와 비교할 수 있도록 previous_close에 강제로 주입하는 핵심 로직 살아있습니다. + +안전 장치: try-except-rollback 구조를 넣어 데이터 정합성을 지켰습니다.