Files
AssetPilot/.TemporaryDocument/main.py.good
2026-02-13 18:48:14 +09:00

303 lines
12 KiB
Plaintext

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"<b>[{icon}] {symbol}</b>\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"<b>💰 목표수익 달성! ({symbol})</b>\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)