금 현물
-KRX 금현물
+비트코인
-업비트 BTC
+총 손익
-📊 자산 현황
- -| 항목 | -전일종가 | +종목 | +전일종가 | 현재가 | 변동 | 변동률 | -평단가 | -보유량 | -매입액 | +평단가 | +보유수량 | +매수금액 | +업데이트 |
|---|
diff --git a/asset_pilot_docker/.venv/bin/httpx b/asset_pilot_docker/.venv/bin/httpx new file mode 100755 index 0000000..57edafa --- /dev/null +++ b/asset_pilot_docker/.venv/bin/httpx @@ -0,0 +1,8 @@ +#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/asset_pilot_docker/DOCKER_GUIDE.md b/asset_pilot_docker/DOCKER_GUIDE.md deleted file mode 100644 index 58bbe53..0000000 --- a/asset_pilot_docker/DOCKER_GUIDE.md +++ /dev/null @@ -1,420 +0,0 @@ -# Asset Pilot - Docker 설치 가이드 - -## 🐳 Docker 방식의 장점 - -- ✅ 독립된 컨테이너로 깔끔한 환경 관리 -- ✅ PostgreSQL과 애플리케이션 분리 -- ✅ 한 번의 명령으로 전체 시스템 실행 -- ✅ 쉬운 백업 및 복구 -- ✅ 포트 충돌 없음 -- ✅ 업데이트 및 롤백 간편 - ---- - -## 📋 사전 준비 - -### 1. Docker 설치 - -#### Orange Pi (Ubuntu/Debian) -```bash -# Docker 설치 스크립트 -curl -fsSL https://get.docker.com -o get-docker.sh -sudo sh get-docker.sh - -# 현재 사용자를 docker 그룹에 추가 -sudo usermod -aG docker $USER - -# 로그아웃 후 재로그인 또는 -newgrp docker - -# Docker 서비스 시작 -sudo systemctl start docker -sudo systemctl enable docker -``` - -#### Docker Compose 설치 (이미 포함되어 있을 수 있음) -```bash -# Docker Compose 버전 확인 -docker compose version - -# 없다면 설치 -sudo apt-get update -sudo apt-get install docker-compose-plugin -``` - -### 2. 설치 확인 -```bash -docker --version -docker compose version -``` - ---- - -## 🚀 설치 및 실행 - -### 1단계: 파일 업로드 - -Orange Pi에 `asset_pilot_docker.tar.gz` 파일을 전송: - -```bash -# Windows에서 (PowerShell) -scp asset_pilot_docker.tar.gz orangepi@192.168.1.100:~/ - -# Linux/Mac에서 -scp asset_pilot_docker.tar.gz orangepi@192.168.1.100:~/ -``` - -### 2단계: 압축 해제 - -```bash -# SSH 접속 -ssh orangepi@192.168.1.100 - -# 압축 해제 -tar -xzf asset_pilot_docker.tar.gz -cd asset_pilot_docker -``` - -### 3단계: 환경 설정 - -```bash -# .env 파일 편집 (비밀번호 변경) -nano .env -``` - -`.env` 파일 내용: -```env -DB_PASSWORD=your_secure_password_here # 여기를 변경하세요! -``` - -저장: `Ctrl + X` → `Y` → `Enter` - -### 4단계: Docker 컨테이너 실행 - -```bash -# 백그라운드에서 실행 -docker compose up -d - -# 실행 상태 확인 -docker compose ps -``` - -출력 예시: -``` -NAME IMAGE STATUS PORTS -asset_pilot_app asset_pilot_docker-app Up 30 seconds 0.0.0.0:8000->8000/tcp -asset_pilot_db postgres:16-alpine Up 30 seconds 0.0.0.0:5432->5432/tcp -``` - -### 5단계: 데이터베이스 초기화 - -```bash -# 앱 컨테이너 내부에서 초기화 스크립트 실행 -docker compose exec app python init_db.py -``` - -### 6단계: 접속 확인 - -웹 브라우저에서: -``` -http://[Orange_Pi_IP]:8000 -``` - -예: `http://192.168.1.100:8000` - ---- - -## 🔧 Docker 관리 명령어 - -### 컨테이너 관리 - -```bash -# 전체 시작 -docker compose up -d - -# 전체 중지 -docker compose down - -# 전체 재시작 -docker compose restart - -# 특정 서비스만 재시작 -docker compose restart app # 앱만 -docker compose restart postgres # DB만 - -# 상태 확인 -docker compose ps - -# 로그 확인 (실시간) -docker compose logs -f - -# 특정 서비스 로그만 -docker compose logs -f app -docker compose logs -f postgres -``` - -### 데이터베이스 관리 - -```bash -# PostgreSQL 컨테이너 접속 -docker compose exec postgres psql -U asset_user -d asset_pilot - -# SQL 쿼리 실행 예시 -# \dt # 테이블 목록 -# \d assets # assets 테이블 구조 -# SELECT * FROM assets; -# \q # 종료 -``` - -### 애플리케이션 관리 - -```bash -# 앱 컨테이너 내부 접속 -docker compose exec app /bin/bash - -# 컨테이너 내부에서 Python 스크립트 실행 -docker compose exec app python init_db.py -``` - ---- - -## 📊 데이터 관리 - -### 백업 - -#### 데이터베이스 백업 -```bash -# 백업 생성 -docker compose exec postgres pg_dump -U asset_user asset_pilot > backup_$(date +%Y%m%d).sql - -# 또는 -docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup.sql -``` - -#### 전체 볼륨 백업 -```bash -# 볼륨 백업 (고급) -docker run --rm -v asset_pilot_docker_postgres_data:/data \ - -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz /data -``` - -### 복원 - -```bash -# 백업 파일 복원 -cat backup.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot -``` - -### CSV 데이터 가져오기 (Windows 앱에서) - -```bash -# 1. CSV 파일을 컨테이너로 복사 -docker cp user_assets.csv asset_pilot_app:/app/ - -# 2. import_csv.py 생성 (아래 스크립트 참고) -docker compose exec app python import_csv.py user_assets.csv -``` - ---- - -## 🔄 업데이트 - -### 애플리케이션 업데이트 - -```bash -# 1. 새 코드 받기 (파일 업로드 또는 git pull) - -# 2. 이미지 재빌드 -docker compose build app - -# 3. 재시작 -docker compose up -d app -``` - -### PostgreSQL 업데이트 - -```bash -# 주의: 데이터 백업 필수! -# 1. 백업 생성 -docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup.sql - -# 2. docker-compose.yml에서 버전 변경 (예: postgres:17-alpine) - -# 3. 컨테이너 재생성 -docker compose down -docker compose up -d -``` - ---- - -## 🗑️ 완전 삭제 - -```bash -# 컨테이너 중지 및 삭제 -docker compose down - -# 볼륨까지 삭제 (데이터 완전 삭제!) -docker compose down -v - -# 이미지도 삭제 -docker rmi asset_pilot_docker-app postgres:16-alpine -``` - ---- - -## 🛠️ 문제 해결 - -### 컨테이너가 시작되지 않음 - -```bash -# 로그 확인 -docker compose logs - -# 특정 서비스 로그 -docker compose logs app -docker compose logs postgres - -# 컨테이너 상태 확인 -docker compose ps -a -``` - -### 데이터베이스 연결 오류 - -```bash -# PostgreSQL 컨테이너 헬스체크 -docker compose exec postgres pg_isready -U asset_user -d asset_pilot - -# 연결 테스트 -docker compose exec postgres psql -U asset_user -d asset_pilot -c "SELECT 1;" -``` - -### 포트 충돌 - -```bash -# 8000번 포트 사용 확인 -sudo lsof -i :8000 - -# docker-compose.yml에서 포트 변경 (예: 8001:8000) -``` - -### 디스크 공간 부족 - -```bash -# 사용하지 않는 Docker 리소스 정리 -docker system prune -a - -# 볼륨 확인 -docker volume ls -``` - ---- - -## 📱 원격 접근 설정 - -### Nginx 리버스 프록시 (선택적) - -```bash -# Nginx 설치 -sudo apt install nginx - -# 설정 파일 생성 -sudo nano /etc/nginx/sites-available/asset_pilot -``` - -설정 내용: -```nginx -server { - listen 80; - server_name your_domain.com; # 또는 IP 주소 - - location / { - proxy_pass http://localhost:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /api/stream { - proxy_pass http://localhost:8000/api/stream; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_buffering off; - proxy_cache off; - } -} -``` - -활성화: -```bash -sudo ln -s /etc/nginx/sites-available/asset_pilot /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl restart nginx -``` - ---- - -## 🔐 보안 권장사항 - -### 1. .env 파일 보호 -```bash -chmod 600 .env -``` - -### 2. 방화벽 설정 -```bash -# 8000번 포트만 허용 (외부 접근 시) -sudo ufw allow 8000/tcp - -# PostgreSQL 포트는 외부 차단 (기본값) -sudo ufw deny 5432/tcp -``` - -### 3. 정기 백업 -```bash -# cron으로 매일 자동 백업 -crontab -e - -# 추가 (매일 새벽 3시) -0 3 * * * cd /home/orangepi/asset_pilot_docker && docker compose exec -T postgres pg_dump -U asset_user asset_pilot > backup_$(date +\%Y\%m\%d).sql -``` - ---- - -## 📊 시스템 리소스 모니터링 - -```bash -# 컨테이너 리소스 사용량 -docker stats - -# 특정 컨테이너만 -docker stats asset_pilot_app asset_pilot_db -``` - ---- - -## ✅ 설치 체크리스트 - -- [ ] Docker 설치 완료 -- [ ] Docker Compose 설치 완료 -- [ ] 프로젝트 파일 압축 해제 -- [ ] .env 파일 비밀번호 설정 -- [ ] `docker compose up -d` 실행 -- [ ] 컨테이너 상태 확인 (`docker compose ps`) -- [ ] 데이터베이스 초기화 (`docker compose exec app python init_db.py`) -- [ ] 웹 브라우저 접속 확인 (`http://[IP]:8000`) -- [ ] 데이터 수집 동작 확인 - ---- - -## 🎉 완료! - -모든 과정이 완료되면 다음 URL로 접속하세요: -``` -http://[Orange_Pi_IP]:8000 -``` - -문제가 발생하면 로그를 확인하세요: -```bash -docker compose logs -f -``` diff --git a/asset_pilot_docker/app/fetcher.py b/asset_pilot_docker/app/fetcher.py index 33627a0..5dda910 100644 --- a/asset_pilot_docker/app/fetcher.py +++ b/asset_pilot_docker/app/fetcher.py @@ -1,113 +1,200 @@ -import requests +import httpx +import asyncio import re -from typing import Dict, Optional 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): - self.session = requests.Session() - self.session.headers.update({ - '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' - }) + # 인베스팅닷컴은 헤더가 없으면 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 = {} - def fetch_investing_com(self, asset_code: str) -> Optional[float]: - """인베스팅닷컴 (윈도우 앱 방식 정규식 적용)""" + async def fetch_google_finance(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]: + """인베스팅보다 빠르고 야후보다 안정적인 구글 파이낸스 우회""" try: - url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}" + # 구글 파이낸스 환율/지수 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('/', '-')}" - # allow_redirects를 True로 하여 주소 변경에 대응 - response = self.session.get(url, timeout=10, allow_redirects=True) + # 인베스팅은 쿠키와 리다이렉트가 중요하므로 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'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: - return float(p.group(1).replace(',', '')) + 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}") + print(f"⚠️ Investing 시스템 에러 ({asset_code}): {e}") return None - def fetch_binance(self) -> Optional[float]: - """바이낸스 BTC/USDT (보내주신 윈도우 코드 로직)""" - url = "https://api.binance.com/api/v3/ticker/price" + async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]: + """바이낸스 BTC/USDT""" try: - response = requests.get(url, params={"symbol": "BTCUSDT"}, timeout=5) - response.raise_for_status() - return float(response.json()["price"]) - except Exception as e: - print(f"❌ Binance API 실패: {e}") - return None + 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 - def fetch_upbit(self) -> Optional[float]: - """업비트 BTC/KRW (보내주신 윈도우 코드 로직)""" - url = "https://api.upbit.com/v1/ticker" + async def fetch_upbit(self, client: httpx.AsyncClient) -> Optional[float]: + """업비트 BTC/KRW""" try: - response = requests.get(url, params={"markets": "KRW-BTC"}, timeout=5) - response.raise_for_status() - data = response.json() - return float(data[0]["trade_price"]) if data else None - except Exception as e: - print(f"❌ Upbit API 실패: {e}") - return None + 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 - def fetch_usd_krw(self) -> Optional[float]: - """USD/KRW 환율 (DNS 에러 방지 이중화)""" - # 방법 1: 두나무 CDN (원래 주소) - try: - url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD" - res = requests.get(url, timeout=3) - if res.status_code == 200: - return float(res.json()[0]["basePrice"]) - except: - pass # 실패하면 바로 인베스팅닷컴으로 전환 - - # 방법 2: 인베스팅닷컴에서 환율 가져오기 (가장 확실한 백업) - return self.fetch_investing_com("USD/KRW") - - def fetch_krx_gold(self) -> Optional[float]: - """금 시세 (네이버 금융 모바일)""" + async def fetch_krx_gold(self, client: httpx.AsyncClient) -> Optional[float]: + """네이버 금 시세 (국내)""" try: url = "https://m.stock.naver.com/marketindex/metals/M04020000" - res = requests.get(url, timeout=5) - m = re.search(r'\"closePrice\":\"([\d,]+)\"', res.text) - return float(m.group(1).replace(",", "")) if m else None - except: - return None + 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 - def fetch_all(self) -> Dict[str, Dict]: - print(f"📊 [{time.strftime('%H:%M:%S')}] 수집 시작...") - - # 1. 환율 먼저 수집 (계산의 핵심) - usd_krw = self.fetch_usd_krw() - - # 2. 나머지 자산 수집 - results = { - "XAU/USD": {"가격": self.fetch_investing_com("XAU/USD"), "단위": "USD/oz"}, - "XAU/CNY": {"가격": self.fetch_investing_com("XAU/CNY"), "단위": "CNY/oz"}, - "XAU/GBP": {"가격": self.fetch_investing_com("XAU/GBP"), "단위": "GBP/oz"}, - "USD/DXY": {"가격": self.fetch_investing_com("USD/DXY"), "단위": "Index"}, - "USD/KRW": {"가격": usd_krw, "단위": "KRW"}, - "BTC/USD": {"가격": self.fetch_binance(), "단위": "USDT"}, - "BTC/KRW": {"가격": self.fetch_upbit(), "단위": "KRW"}, - "KRX/GLD": {"가격": self.fetch_krx_gold(), "단위": "KRW/g"}, - } - - # 3. XAU/KRW 계산 - xau_krw = None - if results["XAU/USD"]["가격"] and usd_krw: - xau_krw = round((results["XAU/USD"]["가격"] / 31.1034768) * usd_krw, 0) - results["XAU/KRW"] = {"가격": xau_krw, "단위": "KRW/g"} - - success_count = sum(1 for v in results.values() if v['가격'] is not None) - print(f"✅ 수집 완료 (성공: {success_count}/9)") - return results + 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() \ No newline at end of file diff --git a/asset_pilot_docker/app/fetcher.py.claude b/asset_pilot_docker/app/fetcher.py.claude deleted file mode 100644 index 9f014d8..0000000 --- a/asset_pilot_docker/app/fetcher.py.claude +++ /dev/null @@ -1,142 +0,0 @@ -import requests -from typing import Dict, Optional -from bs4 import BeautifulSoup -import time - -class DataFetcher: - """모든 자산 가격 수집 클래스""" - - def __init__(self): - self.session = requests.Session() - self.session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - }) - self.investing_cache = {} - self.cache_time = 0 - - def fetch_investing_com(self, asset_code: str) -> Optional[float]: - """Investing.com에서 가격 수집""" - # 간단한 캐싱 (5초) - if time.time() - self.cache_time < 5 and asset_code in self.investing_cache: - return self.investing_cache[asset_code] - - asset_map = { - "XAU/USD": "8830", - "XAU/CNY": "2186", - "XAU/GBP": "8500", - "USD/DXY": "8827" - } - - asset_id = asset_map.get(asset_code) - if not asset_id: - return None - - try: - url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}" - response = self.session.get(url, timeout=5) - response.raise_for_status() - - soup = BeautifulSoup(response.text, 'lxml') - price_elem = soup.select_one('[data-test="instrument-price-last"]') - - if price_elem: - price_text = price_elem.text.strip().replace(',', '') - price = float(price_text) - self.investing_cache[asset_code] = price - return price - except Exception as e: - print(f"Investing.com 수집 실패 ({asset_code}): {e}") - - return None - - def fetch_binance(self) -> Optional[float]: - """바이낸스 BTC/USDT 가격""" - try: - url = "https://api.binance.com/api/v3/ticker/price" - response = self.session.get(url, params={"symbol": "BTCUSDT"}, timeout=5) - response.raise_for_status() - data = response.json() - return float(data["price"]) if "price" in data else None - except Exception as e: - print(f"Binance API 실패: {e}") - return None - - def fetch_upbit(self) -> Optional[float]: - """업비트 BTC/KRW 가격""" - try: - url = "https://api.upbit.com/v1/ticker" - response = self.session.get(url, params={"markets": "KRW-BTC"}, timeout=5) - response.raise_for_status() - data = response.json() - return data[0]["trade_price"] if data and "trade_price" in data[0] else None - except Exception as e: - print(f"Upbit API 실패: {e}") - return None - - def fetch_usd_krw(self) -> Optional[float]: - """USD/KRW 환율""" - try: - url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD" - response = self.session.get(url, timeout=5) - response.raise_for_status() - data = response.json() - return data[0]["basePrice"] if data else None - except Exception as e: - print(f"USD/KRW 수집 실패: {e}") - return None - - def fetch_krx_gold(self) -> Optional[float]: - """한국거래소 금 현물 가격""" - try: - url = "http://www.goldpr.co.kr/gms/default.asp" - response = self.session.get(url, timeout=5) - response.encoding = 'euc-kr' - - soup = BeautifulSoup(response.text, 'lxml') - - # 금 현물 가격 파싱 (사이트 구조에 따라 조정 필요) - price_elem = soup.select_one('table tr:nth-of-type(2) td:nth-of-type(2)') - if price_elem: - price_text = price_elem.text.strip().replace(',', '').replace('원', '') - return float(price_text) - except Exception as e: - print(f"KRX 금 가격 수집 실패: {e}") - - return None - - def fetch_all(self) -> Dict[str, Dict]: - """모든 자산 가격 수집""" - print("📊 데이터 수집 시작...") - - # 개별 자산 수집 - xau_usd = self.fetch_investing_com("XAU/USD") - xau_cny = self.fetch_investing_com("XAU/CNY") - xau_gbp = self.fetch_investing_com("XAU/GBP") - usd_dxy = self.fetch_investing_com("USD/DXY") - usd_krw = self.fetch_usd_krw() - btc_usd = self.fetch_binance() - btc_krw = self.fetch_upbit() - krx_gold = self.fetch_krx_gold() - - # XAU/KRW 계산 (트로이온스 -> 그램당 원화) - xau_krw = None - if xau_usd and usd_krw: - xau_krw = round((xau_usd / 31.1034768) * usd_krw, 0) - - results = { - "XAU/USD": {"가격": xau_usd, "단위": "USD/oz"}, - "XAU/CNY": {"가격": xau_cny, "단위": "CNY/oz"}, - "XAU/GBP": {"가격": xau_gbp, "단위": "GBP/oz"}, - "USD/DXY": {"가격": usd_dxy, "단위": "Index"}, - "USD/KRW": {"가격": usd_krw, "단위": "KRW"}, - "BTC/USD": {"가격": btc_usd, "단위": "USDT"}, - "BTC/KRW": {"가격": btc_krw, "단위": "KRW"}, - "KRX/GLD": {"가격": krx_gold, "단위": "KRW/g"}, - "XAU/KRW": {"가격": xau_krw, "단위": "KRW/g"}, - } - - print(f"✅ 데이터 수집 완료 (성공: {sum(1 for v in results.values() if v['가격'])}/9)") - return results - -# 전역 인스턴스 -fetcher = DataFetcher() diff --git a/asset_pilot_docker/app/models.py b/asset_pilot_docker/app/models.py index 9e03593..cf6e7f5 100644 --- a/asset_pilot_docker/app/models.py +++ b/asset_pilot_docker/app/models.py @@ -15,6 +15,12 @@ class Asset(Base): category = Column(String(50)) # 귀금속, 암호화폐, 환율 등 created_at = Column(DateTime, default=datetime.utcnow) + # --- 새로 추가한 실시간 데이터 컬럼 --- + current_price = Column(Float) # 실시간 현재가 + price_state = Column(String(20), default="stable") # up, down, stable + last_updated = Column(DateTime) # 마지막 수집 시각 + # ------------------------------------ + # 관계 user_assets = relationship("UserAsset", back_populates="asset") price_history = relationship("PriceHistory", back_populates="asset") diff --git a/asset_pilot_docker/docker-compose.yml b/asset_pilot_docker/docker-compose.yml index b50e251..b8a4bea 100644 --- a/asset_pilot_docker/docker-compose.yml +++ b/asset_pilot_docker/docker-compose.yml @@ -9,9 +9,11 @@ services: POSTGRES_USER: asset_user POSTGRES_PASSWORD: ${DB_PASSWORD:-assetpilot} POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + TZ: Asia/Seoul volumes: - postgres_data:/var/lib/postgresql/data - ./init-db:/docker-entrypoint-initdb.d + - /etc/localtime:/etc/localtime:ro ports: - "5432:5432" networks: @@ -27,7 +29,8 @@ services: build: context: . dockerfile: Dockerfile - # DNS 설정 (빌드 밖으로 이동) + # 📌 command를 여기(app 서비스 내부)에 넣었습니다. + command: uvicorn main:app --host 0.0.0.0 --port 8000 dns: - 8.8.8.8 - 1.1.1.1 @@ -37,18 +40,21 @@ services: postgres: condition: service_healthy environment: - # DB 비밀번호 기본값을 postgres 서비스와 동일하게 'assetpilot'으로 설정 DATABASE_URL: postgresql://asset_user:${DB_PASSWORD:-assetpilot}@postgres:5432/asset_pilot APP_HOST: 0.0.0.0 APP_PORT: 8000 DEBUG: "False" FETCH_INTERVAL: 5 + TZ: Asia/Seoul ports: - "8000:8000" networks: - asset_pilot_network volumes: + - .:/app - app_logs:/app/logs + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s diff --git a/asset_pilot_docker/main.py b/asset_pilot_docker/main.py index 5475a47..ef9b82b 100644 --- a/asset_pilot_docker/main.py +++ b/asset_pilot_docker/main.py @@ -1,37 +1,60 @@ import os import json import asyncio -from datetime import datetime +import httpx +from datetime import datetime, timedelta 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.asyncio import AsyncIOScheduler from sqlalchemy.orm import Session from pydantic import BaseModel from dotenv import load_dotenv -from app.database import get_db, engine +from app.database import get_db, engine, SessionLocal 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") +app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.2.0") +# 1. 현재 main.py 파일의 절대 경로를 가져옵니다. +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -# 정적 파일 및 템플릿 설정 -app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") +# 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}") -# 전역 변수: 현재 가격 캐시 -current_prices: Dict = {} +# 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 previous_close: float @@ -41,228 +64,244 @@ class UserAssetUpdate(BaseModel): class AlertSettingUpdate(BaseModel): settings: Dict -# ==================== 데이터베이스 초기화 ==================== +# ==================== 유틸리티 함수 ==================== +async def send_telegram_msg_async(text: str): + """비동기 방식으로 텔레그램 메시지 전송""" + token = os.getenv("TELEGRAM_TOKEN") + chat_id = os.getenv("TELEGRAM_CHAT_ID") + if not token or not chat_id: return -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", "금/원", "귀금속"), - ] + url = f"https://api.telegram.org/bot{token}/sendMessage" + payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"} - 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("✅ 자산 마스터 데이터 초기화 완료") + async with httpx.AsyncClient() as client: + try: + resp = await client.post(url, json=payload, timeout=5) + if resp.status_code != 200: print(f"❌ 텔레그램 실패: {resp.text}") + except Exception as e: print(f"❌ 텔레그램 오류: {e}") -def init_user_assets(db: Session): - """사용자 자산 초기화 (기본값 0)""" - 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()) +# ==================== DB 초기화 ==================== +def init_db_data(): + db = SessionLocal() try: - init_assets(db) - init_user_assets(db) - init_alert_settings(db) - print("🚀 Asset Pilot 서버 시작 완료") + 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: + if not db.query(Asset).filter(Asset.symbol == symbol).first(): + db.add(Asset(symbol=symbol, name=name, category=category)) + db.commit() + + assets = db.query(Asset).all() + for asset in assets: + if not db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first(): + db.add(UserAsset(asset_id=asset.id)) + + default_settings = { + "급등락_감지": False, "급등락_임계값": 3.0, + "목표수익률_감지": False, "목표수익률": 10.0, + "특정가격_감지": False, "금_목표가격": 100000, "BTC_목표가격": 100000000, + } + for key, val in default_settings.items(): + if not db.query(AlertSetting).filter(AlertSetting.setting_key == key).first(): + db.add(AlertSetting(setting_key=key, setting_value=json.dumps(val))) + db.commit() finally: db.close() - - # 백그라운드 데이터 수집 시작 - asyncio.create_task(background_fetch()) +# ==================== 백그라운드 태스크 (Watchdog & 알림 통합) ==================== async def background_fetch(): - """백그라운드에서 주기적으로 가격 수집""" - global current_prices - interval = int(os.getenv('FETCH_INTERVAL', 5)) - + """비동기 수집 루프: DB 업데이트 + Heartbeat + 알림""" while True: try: - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 데이터 수집 시작...") - current_prices = fetcher.fetch_all() + async with clients_lock: + interval = 5 if connected_clients > 0 else 15 + + 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() + + now_ts = datetime.now().timestamp() + + for asset, ua in user_assets: + symbol = asset.symbol + price = asset.current_price + if price is None or price <= 0: continue + + 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)): + if now_ts - last_alert_time.get(f"{symbol}_vol", 0) > 3600: + icon = "🚀 급등" if change > 0 else "📉 급락" + 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)): + if now_ts - last_alert_time.get(f"{symbol}_profit", 0) > 86400: + 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: + await send_telegram_msg_async(f"✨ 금 목표가 돌파!\n현재가: {price:,.0f}원") + last_alert_time["gold_hit"] = now_ts + elif symbol == "BTC/KRW" and price >= float(sets.get("BTC_목표가격", 0)): + 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: - print(f"❌ 데이터 수집 오류: {e}") + system_status["status"] = "error" + print(f"❌ 수집 루프 에러: {e}") await asyncio.sleep(interval) -# ==================== API 엔드포인트 ==================== +# ==================== 앱 생명주기 (AsyncIOScheduler 적용) ==================== +@app.on_event("startup") +async def startup_event(): + init_db_data() + + # [변경] 7시 10분 비동기 전용 스케줄러 작업 + async def daily_job(): + print(f"🌅 [기준가 업데이트 시작] {datetime.now()}") + db = SessionLocal() + try: + await fetcher.update_closing_prices(db) + finally: + db.close() + scheduler.add_job(daily_job, 'cron', hour=7, minute=10, id='daily_snapshot') + scheduler.start() + + asyncio.create_task(background_fetch()) + +@app.on_event("shutdown") +def stop_scheduler(): + scheduler.shutdown() + +# ==================== 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 +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): + is_stale = True + + return { + "server_time": datetime.now().isoformat(), + "fetch_status": "stale" if is_stale else system_status["status"], + "last_heartbeat": system_status["last_fetch_time"].isoformat() if system_status["last_fetch_time"] else None, + "prices": { + a.symbol: { + "가격": a.current_price, + "상태": a.price_state, + "업데이트": a.last_updated.isoformat() if a.last_updated else None + } for a in assets + } + } @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="자산을 찾을 수 없습니다") - - 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", "message": "업데이트 완료"} - - raise HTTPException(status_code=404, detail="사용자 자산 정보를 찾을 수 없습니다") + assets = db.query(Asset, UserAsset).join(UserAsset).all() + return [{ + "symbol": a.symbol, "name": a.name, "category": a.category, + "previous_close": float(ua.previous_close), + "average_price": float(ua.average_price), + "quantity": float(ua.quantity) + } for a, ua in assets] @app.get("/api/pnl") async def get_pnl(db: Session = Depends(get_db)): - """손익 계산""" - # KRX/GLD 자산 정보 - 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/KRW 자산 정보 - 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 - - gold_buy_price = float(krx_user.average_price) if krx_user else 0 - gold_quantity = float(krx_user.quantity) if krx_user else 0 - btc_buy_price = float(btc_user.average_price) if btc_user else 0 - btc_quantity = float(btc_user.quantity) if btc_user else 0 - - current_gold = current_prices.get("KRX/GLD", {}).get("가격") - current_btc = current_prices.get("BTC/KRW", {}).get("가격") + krx = db.query(Asset, UserAsset).join(UserAsset).filter(Asset.symbol == "KRX/GLD").first() + btc = db.query(Asset, UserAsset).join(UserAsset).filter(Asset.symbol == "BTC/KRW").first() pnl = Calculator.calc_pnl( - gold_buy_price, gold_quantity, - btc_buy_price, btc_quantity, - current_gold, current_btc + float(krx[1].average_price) if krx else 0, float(krx[1].quantity) if krx else 0, + float(btc[1].average_price) if btc else 0, float(btc[1].quantity) if btc else 0, + krx[0].current_price if krx else 0, btc[0].current_price if btc else 0 ) - return pnl +@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 + db = SessionLocal() + try: + assets = db.query(Asset).all() + data = {a.symbol: {"가격": a.current_price, "상태": a.price_state} for a in assets} + yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + finally: + db.close() + 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") + +@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() + ua = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first() + if ua: + ua.previous_close, ua.average_price, ua.quantity = data.previous_close, data.average_price, data.quantity + db.commit() + return {"status": "success"} + raise HTTPException(status_code=404) + @app.get("/api/alerts/settings") async def get_alert_settings(db: Session = Depends(get_db)): - """알림 설정 조회""" settings = db.query(AlertSetting).all() - result = {} - for setting in settings: - try: - result[setting.setting_key] = json.loads(setting.setting_value) - except: - result[setting.setting_key] = setting.setting_value - return result + 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: - new_setting = AlertSetting(setting_key=key, setting_value=json.dumps(value)) - db.add(new_setting) - + s = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first() + if s: s.setting_value = json.dumps(value) db.commit() - return {"status": "success", "message": "알림 설정 업데이트 완료"} - -@app.get("/api/stream") -async def stream_prices(): - """Server-Sent Events로 실시간 가격 스트리밍""" - async def event_generator(): - while True: - if current_prices: - data = json.dumps(current_prices, ensure_ascii=False) - yield f"data: {data}\n\n" - await asyncio.sleep(1) - - return StreamingResponse(event_generator(), media_type="text/event-stream") + return {"status": "success"} @app.get("/health") async def health_check(): - """헬스 체크""" - return { - "status": "healthy", - "timestamp": datetime.now().isoformat(), - "prices_loaded": len(current_prices) > 0 - } + return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]} -# ==================== 메인 실행 ==================== - -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=os.getenv("DEBUG", "False").lower() == "true" - ) +# if __name__ == "__main__": +# import uvicorn +# uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) \ No newline at end of file diff --git a/asset_pilot_docker/requirements.txt b/asset_pilot_docker/requirements.txt index c85ccd3..20f81e3 100644 --- a/asset_pilot_docker/requirements.txt +++ b/asset_pilot_docker/requirements.txt @@ -10,3 +10,5 @@ requests==2.31.0 beautifulsoup4==4.12.2 lxml==4.9.3 python-multipart==0.0.6 +apscheduler==3.9.1 +httpx==0.28.1 diff --git a/asset_pilot_docker/static/css/style.css b/asset_pilot_docker/static/css/style.css index 36bab9d..b2371ab 100644 --- a/asset_pilot_docker/static/css/style.css +++ b/asset_pilot_docker/static/css/style.css @@ -1,3 +1,7 @@ +/* Asset Pilot - Redesigned CSS */ + +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Noto+Sans+KR:wght@400;500;600;700&display=swap'); + * { margin: 0; padding: 0; @@ -5,349 +9,709 @@ } :root { + /* 핵심 색상 변수 - app.js profit/loss 연동 보존 */ --primary-color: #2563eb; - --success-color: #10b981; --danger-color: #ef4444; - --warning-color: #f59e0b; - --bg-color: #f8fafc; - --card-bg: #ffffff; - --text-primary: #1e293b; - --text-secondary: #64748b; - --border-color: #e2e8f0; + --success-color: #10b981; + + /* 테마 */ + --bg-color: #161c2a; + --bg-secondary: #1e2636; + --card-bg: #222d42; + --card-border: rgba(255,255,255,0.1); + --text-primary: #f0f4ff; + --text-secondary: #9aa3ba; + --text-muted: #5e6880; + --border-color: rgba(255,255,255,0.08); + --accent-gold: #f59e0b; + --accent-blue: #3b82f6; + + --font-body: 'Noto Sans KR', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; } +/* ─── 기본 ─────────────────────────────────────────── */ body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: var(--font-body); background-color: var(--bg-color); color: var(--text-primary); line-height: 1.6; + min-height: 100vh; +} + +/* 미묘한 배경 그리드 텍스처 */ +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(37,99,235,0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(37,99,235,0.03) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; } .container { max-width: 1400px; margin: 0 auto; - padding: 20px; + padding: 20px 24px; + position: relative; + z-index: 1; } -/* 헤더 */ -header { - background: var(--card-bg); - border-radius: 12px; - padding: 24px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); -} - -header h1 { - font-size: 32px; - margin-bottom: 8px; -} - -.subtitle { - color: var(--text-secondary); - font-size: 14px; - margin-bottom: 12px; -} +/* ─── 손익 색상 (app.js profit/loss 연동 - 절대 수정 금지) ─ */ +.profit { color: var(--danger-color) !important; } +.loss { color: var(--primary-color) !important; } +.numeric.profit, .numeric.loss { font-weight: 600; } +/* ─── 상태바 ─────────────────────────────────────────── */ .status-bar { display: flex; align-items: center; - gap: 8px; - font-size: 14px; - color: var(--text-secondary); + background: #111827; + color: #8892aa; + padding: 7px 20px; + font-size: 0.8em; + font-family: var(--font-mono); + border-bottom: 1px solid rgba(37,99,235,0.25); + letter-spacing: 0.02em; + position: sticky; + top: 0; + z-index: 100; } -.status-dot { - width: 10px; - height: 10px; +.status-bar .status-dot { + width: 8px; + height: 8px; border-radius: 50%; - background-color: var(--success-color); + margin-right: 10px; + flex-shrink: 0; +} + +.status-healthy { + background-color: var(--success-color) !important; + box-shadow: 0 0 8px var(--success-color); animation: pulse 2s infinite; } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } +.status-stale { + background-color: var(--danger-color) !important; + box-shadow: 0 0 8px var(--danger-color); } -/* 손익 요약 */ +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 8px var(--success-color); } + 50% { opacity: 0.6; box-shadow: 0 0 3px var(--success-color); } +} + +#status-indicator { + width: 10px; height: 10px; border-radius: 50%; + display: inline-block; vertical-align: middle; margin-right: 5px; +} + +/* ─── 헤더 ─────────────────────────────────────────── */ +header { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + padding: 20px 28px; + margin-bottom: 20px; + position: relative; + overflow: hidden; +} + +header::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, var(--primary-color), #8b5cf6, var(--accent-gold)); +} + +header h1 { + font-size: clamp(22px, 4vw, 30px); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + margin-bottom: 2px; +} + +header .subtitle { + color: var(--text-secondary); + font-size: 13px; +} + +.header-actions { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +/* ─── 아이콘 버튼 ─────────────────────────────────── */ +.icon-btn { + padding: 8px 14px; + background: var(--bg-secondary); + border: 1px solid var(--card-border); + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.icon-btn:hover { + background: var(--primary-color); + color: white; + border-color: var(--primary-color); + transform: translateY(-1px); +} + +/* ─── 손익 요약 카드 ───────────────────────────────── */ .pnl-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; - margin-bottom: 24px; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 20px; } .pnl-card { background: var(--card-bg); - border-radius: 12px; - padding: 20px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border: 1px solid var(--card-border); + border-radius: 14px; + padding: 18px 22px; + position: relative; + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; } -.pnl-card.total { - background: linear-gradient(135deg, var(--primary-color) 0%, #1e40af 100%); - color: white; +.pnl-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0,0,0,0.3); } .pnl-card h3 { - font-size: 14px; + font-size: 12px; font-weight: 500; - margin-bottom: 12px; - opacity: 0.9; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 10px; } .pnl-value { - font-size: 28px; - font-weight: 700; + font-family: var(--font-mono); + font-size: clamp(18px, 3vw, 26px); + font-weight: 600; margin-bottom: 4px; + color: var(--text-primary); + letter-spacing: -0.02em; } .pnl-percent { - font-size: 16px; - font-weight: 500; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-secondary); } -.pnl-value.profit { - color: var(--danger-color); +/* 총 손익 카드 */ +.pnl-card.total { + background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 60%, #2563eb 100%); + border-color: rgba(59,130,246,0.3); } -.pnl-value.loss { - color: var(--primary-color); -} +.pnl-card.total h3 { color: rgba(255,255,255,0.6); } .pnl-card.total .pnl-value, -.pnl-card.total .pnl-percent { - color: white; +.pnl-card.total .pnl-percent, +.pnl-card.total .profit, +.pnl-card.total .loss { + color: #ffffff !important; } -/* 자산 섹션 */ -.assets-section { +/* 장식 원형 */ +.pnl-card::before { + content: ''; + position: absolute; + right: -20px; top: -20px; + width: 80px; height: 80px; + border-radius: 50%; + background: rgba(255,255,255,0.03); +} + +/* ─── 테이블 섹션 ──────────────────────────────────── */ +.table-section, .assets-section { background: var(--card-bg); - border-radius: 12px; - padding: 24px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - margin-bottom: 24px; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; + border: 1px solid var(--card-border); + border-radius: 16px; + overflow: hidden; margin-bottom: 20px; } -.section-header h2 { - font-size: 20px; +.table-section-header { + padding: 16px 22px; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; } -/* 테이블 */ -.table-container { +/* 테이블 가로 스크롤 래퍼 */ +.table-wrapper { + width: 100%; overflow-x: auto; + -webkit-overflow-scrolling: touch; } table { width: 100%; border-collapse: collapse; + min-width: 720px; /* 가로 스크롤 기준점 */ } -th, td { - padding: 12px 16px; - text-align: left; - border-bottom: 1px solid var(--border-color); +thead tr { + background: rgba(0,0,0,0.2); } th { - background-color: var(--bg-color); + padding: 11px 16px; + font-size: 11px; font-weight: 600; - font-size: 14px; - color: var(--text-secondary); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--border-color); } td { + padding: 13px 16px; font-size: 14px; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; + color: var(--text-primary); + white-space: nowrap; +} + +tbody tr:last-child td { border-bottom: none; } + +tbody tr { + transition: background 0.15s; +} + +tbody tr:hover td { + background: rgba(37,99,235,0.06); } .numeric { text-align: right; + font-family: var(--font-mono); + font-size: 13px; +} + +/* 입력창 컬럼: 내용에 맞게 최소 너비 고정 */ +td.input-cell { + width: 1%; /* shrink-to-fit */ + white-space: nowrap; } td input { - width: 100%; - padding: 6px 8px; + width: clamp(72px, 8vw, 120px); /* 화면 너비 비례, 최소 72 최대 120 */ + padding: 4px 8px; + background: var(--bg-secondary); border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 14px; + border-radius: 5px; + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-mono); + text-align: right; + display: block; } td input:focus { outline: none; border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(37,99,235,0.2); } -.price-up { - color: var(--danger-color); -} +.premium-row td { background-color: rgba(245, 158, 11, 0.04) !important; } -.price-down { - color: var(--primary-color); -} - -/* 버튼 */ +/* ─── 버튼 ────────────────────────────────────────── */ .btn { - padding: 10px 20px; + padding: 9px 18px; border: none; - border-radius: 6px; - font-size: 14px; + border-radius: 7px; + font-size: 13px; font-weight: 500; + font-family: var(--font-body); cursor: pointer; transition: all 0.2s; } .btn-primary { - background-color: var(--primary-color); + background: var(--primary-color); color: white; } -.btn-primary:hover { - background-color: #1e40af; -} +.btn-primary:hover { background: #1e40af; transform: translateY(-1px); } .btn-secondary { - background-color: var(--border-color); - color: var(--text-primary); + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); } -.btn-secondary:hover { - background-color: #cbd5e1; +.btn-secondary:hover { background: var(--border-color); } + +.investing-btn { + display: inline-block; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + text-decoration: none; + min-width: 80px; + text-align: center; + font-family: var(--font-body); + transition: all 0.2s; } -/* 모달 */ +.investing-btn:hover { + background: var(--primary-color); + color: white !important; + border-color: var(--primary-color); + box-shadow: 0 2px 8px rgba(37,99,235,0.4); +} + +/* ─── 모달 ────────────────────────────────────────── */ .modal { display: none; position: fixed; - z-index: 1000; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0,0,0,0.5); -} - -.modal.active { - display: flex; + z-index: 9999; + left: 0; top: 0; + width: 100vw; height: 100vh; + background-color: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); justify-content: center; align-items: center; } -.modal-content { - background-color: var(--card-bg); - border-radius: 12px; - width: 90%; - max-width: 600px; +.modal.active { display: flex !important; } + +.modal .modal-content, .modal-content.card { + background: var(--card-bg) !important; + border: 1px solid var(--card-border) !important; + border-radius: 16px !important; + width: 90% !important; + max-width: 460px !important; max-height: 90vh; overflow-y: auto; - box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); + box-shadow: 0 30px 60px rgba(0,0,0,0.6) !important; + animation: modalFade 0.25s ease-out; + position: relative; } -.modal-header { +/* 모달 상단 컬러 라인 */ +.modal .modal-content::before, +.modal-content.card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, var(--primary-color), #8b5cf6); + border-radius: 16px 16px 0 0; +} + +@keyframes modalFade { + from { opacity: 0; transform: translateY(-16px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal .modal-header { + padding: 20px 24px !important; + border-bottom: 1px solid var(--border-color) !important; display: flex; justify-content: space-between; align-items: center; - padding: 20px 24px; +} + +.modal .modal-header h2, +.modal-content.card h2 { + font-size: 17px !important; + font-weight: 700 !important; + color: var(--text-primary) !important; +} + +.modal .modal-body { + padding: 22px 28px !important; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.modal .setting-group { + width: 100% !important; + margin: 0 0 14px 0 !important; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-color) !important; +} + +.modal .setting-group:last-child { border-bottom: none !important; } + +.modal .setting-group h3 { + font-size: 12px !important; + font-weight: 600 !important; + color: var(--text-muted) !important; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 10px !important; +} + +.modal .setting-group label { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + font-size: 14px !important; + color: var(--text-primary) !important; + margin-bottom: 8px !important; + cursor: pointer; +} + +.modal .setting-group label:last-child { margin-bottom: 0 !important; } + +.modal .setting-group input[type="checkbox"] { + width: 16px; height: 16px; + flex-shrink: 0; + accent-color: var(--primary-color); +} + +.modal .setting-group input[type="number"] { + width: 100px !important; + height: 32px !important; + text-align: right !important; + padding: 0 8px !important; + font-size: 14px !important; + font-family: var(--font-mono); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + margin-left: auto; +} + +.modal .setting-group input[type="number"]:focus { + outline: none; + border-color: var(--primary-color); +} + +.modal .setting-group input[type="number"]::-webkit-inner-spin-button, +.modal .setting-group input[type="number"]::-webkit-outer-spin-button { + margin: 0; +} + +/* 기존 모달 비(非)구조 지원 (card 클래스만 있을 때) */ +.modal-content.card > hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 0; +} + +.modal-content.card > .setting-group { + padding: 14px 24px; border-bottom: 1px solid var(--border-color); } -.modal-header h2 { - font-size: 20px; +.modal-content.card > .setting-group label { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 8px; + cursor: pointer; +} + +.modal-content.card > .setting-group input[type="number"] { + display: block; + margin-top: 8px; + width: 120px; + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + text-align: right; +} + +.modal .sub-setting { + margin-top: 8px; + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +.modal .sub-setting input[type="number"] { + flex: 1; + max-width: 130px; +} + +.modal .modal-footer { + padding: 14px 24px !important; + border-top: 1px solid var(--border-color) !important; + background: rgba(0,0,0,0.15) !important; + display: flex; + justify-content: flex-end; + gap: 10px; + border-radius: 0 0 16px 16px; +} + +.modal-content.card > .modal-footer { + padding: 14px 24px; + border-top: 1px solid var(--border-color); + background: rgba(0,0,0,0.15); + display: flex; + justify-content: flex-end; + gap: 10px; } .close { - font-size: 28px; - font-weight: 300; + font-size: 24px; cursor: pointer; - color: var(--text-secondary); + color: var(--text-muted); + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.15s; } .close:hover { color: var(--text-primary); + background: rgba(255,255,255,0.08); } -.modal-body { - padding: 24px; -} - -.setting-group { - margin-bottom: 24px; - padding-bottom: 24px; - border-bottom: 1px solid var(--border-color); -} - -.setting-group:last-child { - border-bottom: none; -} - -.setting-group h3 { - font-size: 16px; - margin-bottom: 16px; -} - -.setting-group label { - display: block; - margin-bottom: 12px; -} - -.setting-group input[type="checkbox"] { - margin-right: 8px; -} - -.setting-group input[type="number"] { - width: 120px; - padding: 8px; - border: 1px solid var(--border-color); - border-radius: 4px; - margin-left: 8px; -} - -.modal-footer { - display: flex; - justify-content: flex-end; - gap: 12px; - padding: 20px 24px; - border-top: 1px solid var(--border-color); -} - -/* 푸터 */ +/* ─── 푸터 ────────────────────────────────────────── */ footer { text-align: center; - padding: 24px; - color: var(--text-secondary); + padding: 28px; + color: var(--text-muted); + font-size: 12px; } -footer p { - margin-top: 12px; - font-size: 14px; -} +/* ─── 반응형 ───────────────────────────────────────── */ -/* 반응형 */ -@media (max-width: 768px) { - .container { - padding: 12px; - } - - header h1 { - font-size: 24px; - } +/* 태블릿 (768px ~ 1024px) */ +@media (max-width: 1024px) { + .container { padding: 16px; } .pnl-summary { - grid-template-columns: 1fr; + grid-template-columns: repeat(3, 1fr); + gap: 12px; } - table { + .pnl-value { font-size: 20px; } + + header { padding: 16px 20px; } + header h1 { font-size: 24px; } +} + +/* 모바일 (최대 767px) */ +@media (max-width: 767px) { + .container { padding: 12px; } + + /* 헤더 스택 레이아웃 */ + header { + padding: 14px 16px; + margin-bottom: 14px; + } + + header > div { + flex-direction: column !important; + align-items: flex-start !important; + gap: 12px; + } + + header h1 { font-size: 20px; } + header .subtitle { font-size: 12px; } + + .header-actions { + width: 100%; + justify-content: flex-end; + } + + .icon-btn { + padding: 7px 11px; font-size: 12px; } - th, td { - padding: 8px; + /* 손익 카드: 모바일 2+1 레이아웃 */ + .pnl-summary { + grid-template-columns: 1fr 1fr; + gap: 10px; } + + .pnl-card.total { + grid-column: 1 / -1; /* 총 손익은 전체 폭 */ + } + + .pnl-card { padding: 14px 16px; } + .pnl-card h3 { font-size: 11px; margin-bottom: 6px; } + .pnl-value { font-size: 18px; } + .pnl-percent { font-size: 12px; } + + /* 테이블: 가로 스크롤 + 폰트 축소 */ + .table-section, .assets-section { + border-radius: 12px; + } + + th { font-size: 10px; padding: 9px 10px; } + td { font-size: 12px; padding: 10px 10px; } + + /* 모바일: 입력창 더 작게 */ + td input { width: clamp(60px, 14vw, 90px); font-size: 11px; padding: 3px 5px; } + + /* 상태바 텍스트 압축 */ + .status-bar { font-size: 0.72em; padding: 6px 12px; } + + /* 모달 */ + .modal .modal-content, + .modal-content.card { + width: 95% !important; + max-width: none !important; + } + + .modal .modal-body { padding: 16px 18px !important; } + .modal .modal-header { padding: 14px 18px !important; } + .modal .modal-footer { padding: 12px 18px !important; } +} + +/* 초소형 (최대 400px) */ +@media (max-width: 400px) { + .pnl-summary { grid-template-columns: 1fr; } + .pnl-card.total { grid-column: auto; } + header h1 { font-size: 18px; } +} + +/* ─── 스크롤바 스타일 ──────────────────────────────── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* ─── 포커스 접근성 ───────────────────────────────── */ +:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; } diff --git a/asset_pilot_docker/static/js/app.js b/asset_pilot_docker/static/js/app.js index 231586a..c24f46d 100644 --- a/asset_pilot_docker/static/js/app.js +++ b/asset_pilot_docker/static/js/app.js @@ -1,140 +1,245 @@ -// 전역 변수 +/** + * Asset Pilot - Frontend Logic (Integrated Watchdog Version) + */ + +// 전역 변수 - 선임님 기존 변수명 유지 let currentPrices = {}; let userAssets = []; let alertSettings = {}; +let serverTimeOffset = 0; -// 초기화 -document.addEventListener('DOMContentLoaded', () => { - loadAssets(); +const ASSET_SYMBOLS = [ + 'XAU/USD', 'XAU/CNY', 'XAU/GBP', 'USD/DXY', 'USD/KRW', + 'BTC/KRW', 'BTC/USD', 'KRX/GLD', 'XAU/KRW' +]; + +function getInvestingUrl(symbol) { + const mapping = { + 'XAU/USD': 'https://kr.investing.com/currencies/xau-usd', + 'XAU/CNY': 'https://kr.investing.com/currencies/xau-cny', + 'XAU/GBP': 'https://kr.investing.com/currencies/xau-gbp', + 'USD/DXY': 'https://kr.investing.com/indices/usdollar', + 'USD/KRW': 'https://kr.investing.com/currencies/usd-krw', + 'BTC/USD': 'https://www.binance.com/en/trade/BTC_USD?type=spot', + 'BTC/KRW': 'https://upbit.com/exchange?code=CRIX.UPBIT.KRW-BTC', + 'KRX/GLD': 'https://m.stock.naver.com/marketindex/metals/M04020000', + 'XAU/KRW': 'https://kr.investing.com/currencies/xau-krw' + }; + return mapping[symbol] || ''; +} + +document.addEventListener('DOMContentLoaded', async () => { + await Promise.all([loadAssets(), loadInitialPrices()]); + loadAlertSettings(); startPriceStream(); - // 이벤트 리스너 document.getElementById('refresh-btn').addEventListener('click', refreshData); document.getElementById('alert-settings-btn').addEventListener('click', openAlertModal); document.querySelector('.close').addEventListener('click', closeAlertModal); document.getElementById('save-alerts').addEventListener('click', saveAlertSettings); document.getElementById('cancel-alerts').addEventListener('click', closeAlertModal); - - // 주기적 PnL 업데이트 - setInterval(updatePnL, 1000); + + setInterval(checkDataFreshness, 1000); }); -// 자산 데이터 로드 -async function loadAssets() { +async function loadInitialPrices() { try { - const response = await fetch('/api/assets'); - userAssets = await response.json(); - renderAssetsTable(); - } catch (error) { - console.error('자산 로드 실패:', error); - } + const response = await fetch('/api/prices'); + if (response.ok) { + const result = await response.json(); + processPriceData(result); + } + } catch (error) { console.error('초기 가격 로드 실패:', error); } } -// 알림 설정 로드 -async function loadAlertSettings() { - try { - const response = await fetch('/api/alerts/settings'); - alertSettings = await response.json(); - } catch (error) { - console.error('알림 설정 로드 실패:', error); +function processPriceData(result) { + currentPrices = result.prices || result; + + if (result.fetch_status) { + updateSystemStatus(result.fetch_status, result.last_heartbeat, result.server_time); } + + updatePricesInTable(); + calculatePnLRealtime(); } -// 실시간 가격 스트리밍 function startPriceStream() { const eventSource = new EventSource('/api/stream'); - eventSource.onmessage = (event) => { - currentPrices = JSON.parse(event.data); + const data = JSON.parse(event.data); + currentPrices = data; updatePricesInTable(); - updateLastUpdateTime(); + calculatePnLRealtime(); + document.getElementById('status-indicator').style.backgroundColor = '#10b981'; }; - eventSource.onerror = () => { - console.error('SSE 연결 오류'); document.getElementById('status-indicator').style.backgroundColor = '#ef4444'; }; } -// 테이블 렌더링 +function updateSystemStatus(status, lastHeartbeat, serverTimeStr) { + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + const syncTime = document.getElementById('last-sync-time'); + + if (dot && text) { + if (status === 'healthy') { + dot.className = "status-dot status-healthy"; + text.innerText = "데이터 수집 엔진 정상"; + } else { + dot.className = "status-dot status-stale"; + text.innerText = "수집 지연 (Watchdog 감지)"; + } + } + + if (syncTime && lastHeartbeat) { + const hbTime = lastHeartbeat.split('T')[1].substring(0, 8); + syncTime.innerText = `Last Heartbeat: ${hbTime}`; + } +} + +function checkDataFreshness() { + const now = new Date(); + + ASSET_SYMBOLS.forEach(symbol => { + const priceData = currentPrices[symbol]; + if (!priceData || !priceData.업데이트) return; + + const updateTime = new Date(priceData.업데이트); + const diffSeconds = Math.floor((now - updateTime) / 1000); + + const row = document.querySelector(`tr[data-symbol="${symbol}"]`); + if (!row) return; + + const timeCell = row.querySelector('.update-time-cell'); + if (timeCell) { + const timeStr = priceData.업데이트.split('T')[1].substring(0, 8); + if (diffSeconds > 60) { + timeCell.innerHTML = `⚠️ ${timeStr}`; + } else { + timeCell.innerText = timeStr; + timeCell.style.color = '#666'; + } + } + }); +} + +// ─── 천단위 표시 헬퍼 ──────────────────────────────────────── +// 평상시: type="text" + 천단위 쉼표 표시 +// 포커스 시: type="number" + 원본 숫자로 편집 가능 +// blur 시: 다시 천단위 표시로 전환 + +function attachThousandFormat(input) { + // 초기 rawValue 저장 및 표시 + input.dataset.rawValue = input.value; + _showFormatted(input); + + input.addEventListener('focus', () => { + input.type = 'number'; + input.value = input.dataset.rawValue || ''; + }); + + input.addEventListener('blur', () => { + // blur 시점의 값을 rawValue에 저장 + if (input.value !== '') { + input.dataset.rawValue = input.value; + } + _showFormatted(input); + }); + + // change 이벤트에서도 rawValue 동기화 (외부 change 핸들러가 읽기 전에) + input.addEventListener('change', () => { + input.dataset.rawValue = input.value; + }); +} + +function _showFormatted(input) { + const val = parseFloat(input.dataset.rawValue); + if (!isNaN(val)) { + input.type = 'text'; + input.value = val.toLocaleString('ko-KR'); + } +} + +// rawValue에서 숫자를 안전하게 읽는 헬퍼 +function getRawValue(input) { + return parseFloat(input.dataset.rawValue ?? input.value) || 0; +} + +// 외부(updatePricesInTable)에서 프로그래밍 방식으로 값 갱신 시 사용 +function setRawValue(input, num) { + input.dataset.rawValue = num; + // 현재 포커스 중이 아닐 때만 표시 갱신 + if (document.activeElement !== input) { + _showFormatted(input); + } +} + function renderAssetsTable() { const tbody = document.getElementById('assets-tbody'); tbody.innerHTML = ''; - const assets = [ - 'XAU/USD', 'XAU/CNY', 'XAU/GBP', 'USD/DXY', 'USD/KRW', - 'BTC/USD', 'BTC/KRW', 'KRX/GLD', 'XAU/KRW' - ]; - - assets.forEach(symbol => { - const asset = userAssets.find(a => a.symbol === symbol); - if (!asset) return; + ASSET_SYMBOLS.forEach(symbol => { + const asset = userAssets.find(a => a.symbol === symbol) || { + symbol: symbol, previous_close: 0, average_price: 0, quantity: 0 + }; const row = document.createElement('tr'); row.dataset.symbol = symbol; - - const decimalPlaces = symbol.includes('BTC') ? 8 : 2; - row.innerHTML = ` -
실시간 자산 모니터링 시스템
-