import os import json import asyncio import requests from datetime import datetime from typing import Dict 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.background import BackgroundScheduler from sqlalchemy.orm import Session from pydantic import BaseModel from dotenv import load_dotenv from app.database import get_db, engine from app.models import Base, Asset, UserAsset, AlertSetting from app.fetcher import fetcher from app.calculator import Calculator load_dotenv() # 데이터베이스 테이블 생성 Base.metadata.create_all(bind=engine) app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.0.0") # 1. 스케줄러 설정 (Asia/Seoul 타임존) scheduler = BackgroundScheduler(timezone="Asia/Seoul") # 정적 파일 및 템플릿 설정 app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") # 전역 변수: 현재 가격 캐시 및 연결 상태 관리 current_prices: Dict = {} connected_clients = 0 clients_lock = asyncio.Lock() # 텔레그램 알림용 전역 변수 (메모리상에서 중복 알림 방지) last_alert_time = {} # ==================== Pydantic 모델 ==================== class UserAssetUpdate(BaseModel): symbol: str previous_close: float average_price: float quantity: float class AlertSettingUpdate(BaseModel): settings: Dict # ==================== 유틸리티 함수 ==================== def send_telegram_msg(text: str): """.env에 설정된 토큰과 ID를 사용하여 텔레그램 메시지 전송""" token = os.getenv("TELEGRAM_TOKEN") chat_id = os.getenv("TELEGRAM_CHAT_ID") if not token or not chat_id: print("⚠️ 텔레그램 설정이 .env에 없습니다.") return url = f"https://api.telegram.org/bot{token}/sendMessage" payload = { "chat_id": chat_id, "text": text, "parse_mode": "HTML" } try: response = requests.post(url, json=payload, timeout=5) if response.status_code != 200: print(f"❌ 텔레그램 전송 실패: {response.text}") else: print(f"✅ 텔레그램 알림 발송 성공: {text[:20]}...") except Exception as e: print(f"❌ 텔레그램 연결 오류: {e}") # ==================== 데이터베이스 초기화 로직 ==================== def init_assets(db: Session): assets_data = [ ("XAU/USD", "금/달러", "귀금속"), ("XAU/CNY", "금/위안", "귀금속"), ("XAU/GBP", "금/파운드", "귀금속"), ("USD/DXY", "달러인덱스", "환율"), ("USD/KRW", "달러/원", "환율"), ("BTC/USD", "비트코인/달러", "암호화폐"), ("BTC/KRW", "비트코인/원", "암호화폐"), ("KRX/GLD", "금 현물", "귀금속"), ("XAU/KRW", "금/원", "귀금속"), ] for symbol, name, category in assets_data: existing = db.query(Asset).filter(Asset.symbol == symbol).first() if not existing: asset = Asset(symbol=symbol, name=name, category=category) db.add(asset) db.commit() print("✅ 자산 마스터 데이터 초기화 완료") def init_user_assets(db: Session): assets = db.query(Asset).all() for asset in assets: existing = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first() if not existing: user_asset = UserAsset(asset_id=asset.id, previous_close=0, average_price=0, quantity=0) db.add(user_asset) db.commit() print("✅ 사용자 자산 데이터 초기화 완료") def init_alert_settings(db: Session): default_settings = { "급등락_감지": False, "급등락_임계값": 3.0, "목표수익률_감지": False, "목표수익률": 10.0, "특정가격_감지": False, "금_목표가격": 100000, "BTC_목표가격": 100000000, } for key, value in default_settings.items(): existing = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first() if not existing: setting = AlertSetting(setting_key=key, setting_value=json.dumps(value)) db.add(setting) db.commit() print("✅ 알림 설정 초기화 완료") # ==================== 앱 시작/종료 이벤트 ==================== @app.on_event("startup") async def startup_event(): db = next(get_db()) try: init_assets(db) init_user_assets(db) init_alert_settings(db) print("🚀 데이터베이스 초기화 완료") finally: db.close() scheduler.add_job(fetcher.update_closing_prices, 'cron', hour=7, minute=10, id='daily_snapshot') scheduler.start() if not hasattr(fetcher, 'daily_closing_prices') or not fetcher.daily_closing_prices: fetcher.update_closing_prices() asyncio.create_task(background_fetch()) @app.on_event("shutdown") def stop_scheduler(): scheduler.shutdown() print("🛑 스케줄러 정지") # ==================== 핵심 가변 수집 및 알림 로직 (에러 수정됨) ==================== async def background_fetch(): global current_prices while True: try: async with clients_lock: current_count = connected_clients interval = 5 if current_count > 0 else 10 current_prices = fetcher.fetch_all() if current_prices: db = next(get_db()) try: 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() for asset, ua in user_assets: symbol = asset.symbol if symbol not in current_prices: continue # [중요] 수집된 데이터가 None이거나 에러인 경우 건너뜀 (NoneType 에러 방지) price_data = current_prices[symbol].get("가격") if price_data is None: continue curr_p = float(price_data) prev_c = float(ua.previous_close) if ua.previous_close else 0 avg_p = float(ua.average_price) if ua.average_price else 0 now_ts = datetime.now().timestamp() # 1. 급등락 체크 if sets.get("급등락_감지") and prev_c > 0: change = ((curr_p - prev_c) / prev_c) * 100 if abs(change) >= float(sets.get("급등락_임계값", 3.0)): if now_ts - last_alert_time.get(f"{symbol}_vol", 0) > 3600: icon = "🚀 급등" if change > 0 else "📉 급락" send_telegram_msg(f"[{icon}] {symbol}\n현재가: {curr_p:,.2f}\n변동률: {change:+.2f}%") last_alert_time[f"{symbol}_vol"] = now_ts # 2. 목표수익률 체크 if sets.get("목표수익률_감지") and avg_p > 0: profit = ((curr_p - avg_p) / avg_p) * 100 if profit >= float(sets.get("목표수익률", 10.0)): if now_ts - last_alert_time.get(f"{symbol}_profit", 0) > 86400: send_telegram_msg(f"💰 목표수익 달성! ({symbol})\n수익률: {profit:+.2f}%\n현재가: {curr_p:,.2f}") last_alert_time[f"{symbol}_profit"] = now_ts finally: db.close() except Exception as e: # 예기치 못한 에러 발생 시 로그를 찍고 다음 루프로 진행 print(f"❌ 백그라운드 작업 중 에러 발생: {e}") await asyncio.sleep(interval) # ==================== API 엔드포인트 ==================== @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): return templates.TemplateResponse("index.html", {"request": request}) @app.get("/api/prices") async def get_prices(): return current_prices @app.get("/api/assets") async def get_assets(db: Session = Depends(get_db)): assets = db.query(Asset, UserAsset).join(UserAsset, Asset.id == UserAsset.asset_id).all() result = [] for asset, user_asset in assets: result.append({ "symbol": asset.symbol, "name": asset.name, "category": asset.category, "previous_close": float(user_asset.previous_close), "average_price": float(user_asset.average_price), "quantity": float(user_asset.quantity), }) return result @app.post("/api/assets") async def update_asset(data: UserAssetUpdate, db: Session = Depends(get_db)): asset = db.query(Asset).filter(Asset.symbol == data.symbol).first() if not asset: raise HTTPException(status_code=404, detail="Asset not found") user_asset = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first() if user_asset: user_asset.previous_close = data.previous_close user_asset.average_price = data.average_price user_asset.quantity = data.quantity db.commit() return {"status": "success"} raise HTTPException(status_code=404, detail="UserAsset not found") @app.get("/api/pnl") async def get_pnl(db: Session = Depends(get_db)): krx_asset = db.query(Asset).filter(Asset.symbol == "KRX/GLD").first() krx_user = db.query(UserAsset).filter(UserAsset.asset_id == krx_asset.id).first() if krx_asset else None btc_asset = db.query(Asset).filter(Asset.symbol == "BTC/KRW").first() btc_user = db.query(UserAsset).filter(UserAsset.asset_id == btc_asset.id).first() if btc_asset else None pnl = Calculator.calc_pnl( float(krx_user.average_price) if krx_user else 0, float(krx_user.quantity) if krx_user else 0, float(btc_user.average_price) if btc_user else 0, float(btc_user.quantity) if btc_user else 0, current_prices.get("KRX/GLD", {}).get("가격"), current_prices.get("BTC/KRW", {}).get("가격") ) return pnl @app.get("/api/alerts/settings") async def get_alert_settings(db: Session = Depends(get_db)): settings = db.query(AlertSetting).all() return {s.setting_key: json.loads(s.setting_value) for s in settings} @app.post("/api/alerts/settings") async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(get_db)): for key, value in data.settings.items(): setting = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first() if setting: setting.setting_value = json.dumps(value) else: db.add(AlertSetting(setting_key=key, setting_value=json.dumps(value))) db.commit() return {"status": "success"} @app.get("/api/stream") async def stream_prices(request: Request): async def event_generator(): global connected_clients async with clients_lock: connected_clients += 1 try: while True: if await request.is_disconnected(): break if current_prices: yield f"data: {json.dumps(current_prices, ensure_ascii=False)}\n\n" await asyncio.sleep(5) finally: async with clients_lock: connected_clients = max(0, connected_clients - 1) return StreamingResponse(event_generator(), media_type="text/event-stream") # 도커 Health Check용 경로 수정 (404 방지) @app.get("/health") @app.get("/health/") async def health_check(): return {"status": "healthy"} if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host=os.getenv("APP_HOST", "0.0.0.0"), port=int(os.getenv("APP_PORT", 8000)), reload=False)