From d4f1ca87ab7f1292db644da6b64b8d099a6c8e96 Mon Sep 17 00:00:00 2001 From: Wind Date: Fri, 13 Feb 2026 18:40:48 +0900 Subject: [PATCH] Very Good Web Server Work --- asset_pilot_docker/.venv/bin/httpx | 8 + asset_pilot_docker/DOCKER_GUIDE.md | 420 ------------- asset_pilot_docker/app/fetcher.py | 255 +++++--- asset_pilot_docker/app/fetcher.py.claude | 142 ----- asset_pilot_docker/app/models.py | 6 + asset_pilot_docker/docker-compose.yml | 10 +- asset_pilot_docker/main.py | 423 +++++++------ asset_pilot_docker/requirements.txt | 2 + asset_pilot_docker/static/css/style.css | 756 +++++++++++++++++------ asset_pilot_docker/static/js/app.js | 516 +++++++++------- asset_pilot_docker/templates/favicon.ico | Bin 0 -> 70274 bytes asset_pilot_docker/templates/index.html | 183 +++--- 12 files changed, 1369 insertions(+), 1352 deletions(-) create mode 100755 asset_pilot_docker/.venv/bin/httpx delete mode 100644 asset_pilot_docker/DOCKER_GUIDE.md delete mode 100644 asset_pilot_docker/app/fetcher.py.claude create mode 100644 asset_pilot_docker/templates/favicon.ico 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 = ` - ${symbol} - - - - N/A - N/A - N/A - - - - - - + ${symbol} + + Loading... + 0 + 0% + + 0 + - `; - tbody.appendChild(row); + + // 전일종가, 평단가에만 천단위 포맷 적용 + attachThousandFormat(row.querySelector('.prev-close')); + attachThousandFormat(row.querySelector('.avg-price')); + + if (symbol === 'BTC/USD') insertPremiumRow(tbody, 'BTC_PREMIUM', '📊 BTC 프리미엄'); + else if (symbol === 'XAU/KRW') insertPremiumRow(tbody, 'GOLD_PREMIUM', '✨ GOLD 프리미엄)'); }); - // 입력 필드 이벤트 리스너 - document.querySelectorAll('input[type="number"]').forEach(input => { - input.addEventListener('change', handleAssetChange); - input.addEventListener('blur', handleAssetChange); + document.querySelectorAll('#assets-tbody input').forEach(input => { + input.addEventListener('change', (e) => { + handleAssetChange(e); + updatePricesInTable(); + }); }); } -// 테이블에 가격 업데이트 function updatePricesInTable() { - const rows = document.querySelectorAll('#assets-tbody tr'); - + const rows = document.querySelectorAll('#assets-tbody tr:not(.premium-row)'); + const usdKrw = currentPrices['USD/KRW']?.가격 || 0; + rows.forEach(row => { const symbol = row.dataset.symbol; const priceData = currentPrices[symbol]; - - if (!priceData || !priceData.가격) { - return; - } + if (!priceData || !priceData.가격) return; const currentPrice = priceData.가격; - const prevClose = parseFloat(row.querySelector('.prev-close').value) || 0; + const decimalPlaces = (symbol.includes('USD') || symbol.includes('DXY')) ? 2 : 0; + + // XAU/KRW: KRX/GLD 가격을 전일종가 input에 실시간 주입 + if (symbol === 'XAU/KRW' && currentPrices['KRX/GLD']) { + const prevCloseInput = row.querySelector('.prev-close'); + setRawValue(prevCloseInput, currentPrices['KRX/GLD'].가격); + } + + const prevCloseInput = row.querySelector('.prev-close'); + const prevClose = getRawValue(prevCloseInput); - // 현재가 표시 - const decimalPlaces = symbol.includes('USD') || symbol.includes('DXY') ? 2 : 0; - row.querySelector('.current-price').textContent = formatNumber(currentPrice, decimalPlaces); + const currentPriceCell = row.querySelector('.current-price'); + currentPriceCell.textContent = formatNumber(currentPrice, decimalPlaces); - // 변동 계산 const change = currentPrice - prevClose; const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0; @@ -143,91 +248,107 @@ function updatePricesInTable() { changeCell.textContent = formatNumber(change, decimalPlaces); changePercentCell.textContent = `${formatNumber(changePercent, 2)}%`; - - // 색상 적용 - const colorClass = change > 0 ? 'price-up' : change < 0 ? 'price-down' : ''; - changeCell.className = `numeric ${colorClass}`; - changePercentCell.className = `numeric ${colorClass}`; - - // 매입액 계산 - const avgPrice = parseFloat(row.querySelector('.avg-price').value) || 0; - const quantity = parseFloat(row.querySelector('.quantity').value) || 0; - const buyTotal = avgPrice * quantity; - row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0); - }); -} -// 손익 업데이트 -async function updatePnL() { - try { - const response = await fetch('/api/pnl'); - const pnl = await response.json(); - - // 금 손익 - updatePnLCard('gold-pnl', 'gold-percent', pnl.금손익, pnl['금손익%']); - - // BTC 손익 - updatePnLCard('btc-pnl', 'btc-percent', pnl.BTC손익, pnl['BTC손익%']); - - // 총 손익 - updatePnLCard('total-pnl', 'total-percent', pnl.총손익, pnl['총손익%']); - - } catch (error) { - console.error('PnL 업데이트 실패:', error); - } -} - -// PnL 카드 업데이트 -function updatePnLCard(valueId, percentId, value, percent) { - const valueElem = document.getElementById(valueId); - const percentElem = document.getElementById(percentId); - - valueElem.textContent = formatNumber(value, 0) + ' 원'; - percentElem.textContent = formatNumber(percent, 2) + '%'; - - // 총손익이 아닌 경우만 색상 적용 - if (valueId !== 'total-pnl') { - valueElem.className = `pnl-value ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`; - percentElem.className = `pnl-percent ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`; - } -} - -// 자산 변경 처리 -async function handleAssetChange(event) { - const input = event.target; - const symbol = input.dataset.symbol; - const row = input.closest('tr'); - - const previousClose = parseFloat(row.querySelector('.prev-close').value) || 0; - const averagePrice = parseFloat(row.querySelector('.avg-price').value) || 0; - const quantity = parseFloat(row.querySelector('.quantity').value) || 0; - - try { - const response = await fetch('/api/assets', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - symbol, - previous_close: previousClose, - average_price: averagePrice, - quantity: quantity - }) + // [교정] 선임님 기존 클래스명 profit/loss로 정확히 변경 + const cellsToColor = [currentPriceCell, changeCell, changePercentCell]; + cellsToColor.forEach(cell => { + cell.classList.remove('profit', 'loss'); + if (prevClose > 0) { + if (change > 0) cell.classList.add('profit'); + else if (change < 0) cell.classList.add('loss'); + } }); - if (response.ok) { - console.log(`✅ ${symbol} 업데이트 완료`); - // 매입액 즉시 업데이트 - const buyTotal = averagePrice * quantity; - row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0); + const avgPrice = getRawValue(row.querySelector('.avg-price')); + const quantity = parseFloat(row.querySelector('.quantity').value) || 0; + row.querySelector('.buy-total').textContent = formatNumber(avgPrice * quantity, 0); + }); + + if (usdKrw > 0) { + const btcKrw = currentPrices['BTC/KRW']?.가격; + const btcUsd = currentPrices['BTC/USD']?.가격; + if (btcKrw && btcUsd) { + const btcGlobalInKrw = btcUsd * usdKrw; + const btcPrem = btcKrw - btcGlobalInKrw; + const btcPremPct = (btcPrem / btcGlobalInKrw) * 100; + const btcRow = document.getElementById('BTC_PREMIUM'); + const valCell = btcRow.querySelector('.premium-value'); + const diffCell = btcRow.querySelector('.premium-diff'); + valCell.textContent = formatNumber(btcPrem, 0); + diffCell.textContent = `(차이: ${formatNumber(btcPremPct, 2)}%)`; + [valCell, diffCell].forEach(c => { + c.classList.remove('profit', 'loss'); + if (btcPrem > 0) c.classList.add('profit'); else if (btcPrem < 0) c.classList.add('loss'); + }); + } + + const krxGold = currentPrices['KRX/GLD']?.가격; + const xauUsd = currentPrices['XAU/USD']?.가격; + if (krxGold && xauUsd) { + const goldGlobalInKrw = (xauUsd / 31.1035) * usdKrw; + const goldPrem = krxGold - goldGlobalInKrw; + const goldPremPct = (goldPrem / goldGlobalInKrw) * 100; + const goldRow = document.getElementById('GOLD_PREMIUM'); + const valCell = goldRow.querySelector('.premium-value'); + const diffCell = goldRow.querySelector('.premium-diff'); + valCell.textContent = formatNumber(goldPrem, 0); + diffCell.textContent = `(차이: ${formatNumber(goldPremPct, 2)}%)`; + [valCell, diffCell].forEach(c => { + c.classList.remove('profit', 'loss'); + if (goldPrem > 0) c.classList.add('profit'); else if (goldPrem < 0) c.classList.add('loss'); + }); } - } catch (error) { - console.error('업데이트 실패:', error); } } -// 알림 설정 모달 열기 +async function loadAssets() { try { const r = await fetch('/api/assets'); userAssets = await r.json(); renderAssetsTable(); } catch(e) { console.error(e); } } +async function loadAlertSettings() { try { const r = await fetch('/api/alerts/settings'); alertSettings = await r.json(); } catch(e) { console.error(e); } } +function insertPremiumRow(tbody, id, label) { + const row = document.createElement('tr'); + row.id = id; row.className = 'premium-row'; + row.innerHTML = `${label}-계산중...-`; + tbody.appendChild(row); +} +function calculatePnLRealtime() { + let totalBuy = 0, totalCurrentValue = 0; + let goldBuy = 0, goldCurrent = 0, btcBuy = 0, btcCurrent = 0; + const usdKrw = currentPrices['USD/KRW']?.가격 || 1400; + const rows = document.querySelectorAll('#assets-tbody tr:not(.premium-row)'); + rows.forEach(row => { + const symbol = row.dataset.symbol; + const priceData = currentPrices[symbol]; + if (!priceData) return; + const currentPrice = priceData.가격; + const avgPrice = getRawValue(row.querySelector('.avg-price')); + const quantity = parseFloat(row.querySelector('.quantity').value) || 0; + let buyValue = avgPrice * quantity; + let currentValue = currentPrice * quantity; + if (symbol.includes('USD') && symbol !== 'USD/KRW') { buyValue *= usdKrw; currentValue *= usdKrw; } + totalBuy += buyValue; totalCurrentValue += currentValue; + if (symbol.includes('XAU') || symbol.includes('GLD')) { goldBuy += buyValue; goldCurrent += currentValue; } + else if (symbol.includes('BTC')) { btcBuy += buyValue; btcCurrent += currentValue; } + }); + updatePnLCard('total-pnl', 'total-percent', totalCurrentValue - totalBuy, totalBuy > 0 ? ((totalCurrentValue - totalBuy) / totalBuy * 100) : 0); + updatePnLCard('gold-pnl', 'gold-percent', goldCurrent - goldBuy, goldBuy > 0 ? ((goldCurrent - goldBuy) / goldBuy * 100) : 0); + updatePnLCard('btc-pnl', 'btc-percent', btcCurrent - btcBuy, btcBuy > 0 ? ((btcCurrent - btcBuy) / btcBuy * 100) : 0); +} +function updatePnLCard(vId, pId, v, p) { + const vE = document.getElementById(vId), pE = document.getElementById(pId); + if (!vE || !pE) return; + vE.textContent = formatNumber(v, 0) + ' 원'; pE.textContent = formatNumber(p, 2) + '%'; + const sC = v > 0 ? 'profit' : v < 0 ? 'loss' : ''; + vE.className = `pnl-value ${sC}`; pE.className = `pnl-percent ${sC}`; +} +async function handleAssetChange(e) { + const i = e.target; const s = i.dataset.symbol; const r = i.closest('tr'); + const d = { + symbol: s, + previous_close: getRawValue(r.querySelector('.prev-close')), + average_price: getRawValue(r.querySelector('.avg-price')), + quantity: parseFloat(r.querySelector('.quantity').value) || 0 + }; + try { await fetch('/api/assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) }); calculatePnLRealtime(); } catch(err) { console.error(err); } +} function openAlertModal() { document.getElementById('급등락_감지').checked = alertSettings.급등락_감지 || false; document.getElementById('급등락_임계값').value = alertSettings.급등락_임계값 || 3.0; @@ -236,74 +357,13 @@ function openAlertModal() { document.getElementById('특정가격_감지').checked = alertSettings.특정가격_감지 || false; document.getElementById('금_목표가격').value = alertSettings.금_목표가격 || 100000; document.getElementById('BTC_목표가격').value = alertSettings.BTC_목표가격 || 100000000; - document.getElementById('alert-modal').classList.add('active'); } - -// 알림 설정 모달 닫기 -function closeAlertModal() { - document.getElementById('alert-modal').classList.remove('active'); -} - -// 알림 설정 저장 +function closeAlertModal() { document.getElementById('alert-modal').classList.remove('active'); } async function saveAlertSettings() { - const settings = { - 급등락_감지: document.getElementById('급등락_감지').checked, - 급등락_임계값: parseFloat(document.getElementById('급등락_임계값').value), - 목표수익률_감지: document.getElementById('목표수익률_감지').checked, - 목표수익률: parseFloat(document.getElementById('목표수익률').value), - 특정가격_감지: document.getElementById('특정가격_감지').checked, - 금_목표가격: parseInt(document.getElementById('금_목표가격').value), - BTC_목표가격: parseInt(document.getElementById('BTC_목표가격').value) - }; - - try { - const response = await fetch('/api/alerts/settings', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ settings }) - }); - - if (response.ok) { - alertSettings = settings; - console.log('✅ 알림 설정 저장 완료'); - closeAlertModal(); - } - } catch (error) { - console.error('알림 설정 저장 실패:', error); - } + const settings = { 급등락_감지: document.getElementById('급등락_감지').checked, 급등락_임계값: parseFloat(document.getElementById('급등락_임계값').value), 목표수익률_감지: document.getElementById('목표수익률_감지').checked, 목표수익률: parseFloat(document.getElementById('목표수익률').value), 특정가격_감지: document.getElementById('특정가격_감지').checked, 금_목표가격: parseInt(document.getElementById('금_목표가격').value), BTC_목표가격: parseInt(document.getElementById('BTC_목표가격').value) }; + try { const r = await fetch('/api/alerts/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings }) }); if (r.ok) { alertSettings = settings; closeAlertModal(); } } catch(err) { console.error(err); } } - -// 데이터 새로고침 -async function refreshData() { - await loadAssets(); - console.log('🔄 데이터 새로고침 완료'); -} - -// 마지막 업데이트 시간 표시 -function updateLastUpdateTime() { - const now = new Date(); - const timeString = now.toLocaleTimeString('ko-KR'); - document.getElementById('last-update').textContent = `마지막 업데이트: ${timeString}`; -} - -// 숫자 포맷팅 -function formatNumber(value, decimals = 0) { - if (value === null || value === undefined || isNaN(value)) { - return 'N/A'; - } - return value.toLocaleString('ko-KR', { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals - }); -} - -// 외부 클릭 시 모달 닫기 -window.addEventListener('click', (event) => { - const modal = document.getElementById('alert-modal'); - if (event.target === modal) { - closeAlertModal(); - } -}); +async function refreshData() { await Promise.all([loadAssets(), loadInitialPrices()]); } +function formatNumber(v, d = 0) { if (v === null || v === undefined || isNaN(v)) return 'N/A'; return v.toLocaleString('ko-KR', { minimumFractionDigits: d, maximumFractionDigits: d }); } +window.addEventListener('click', (e) => { if (e.target === document.getElementById('alert-modal')) closeAlertModal(); }); diff --git a/asset_pilot_docker/templates/favicon.ico b/asset_pilot_docker/templates/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..13de194065e013cf14349d23fa97e70dd08d3474 GIT binary patch literal 70274 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 Z{<5Ez;q)-1nqm9F&ejnJ{D|d9I%dP4Fmf%(T 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 Asset Pilot - 자산 모니터 - + + + + + + +
+ + 시스템 가동 중 + +
+ +
+
+
+
+ +
-

💰 Asset Pilot

-

실시간 자산 모니터링 시스템

-
- - 데이터 수집 중... +
+
+

💰 Asset Pilot

+

실시간 자산 모니터링 시스템

+
+
+ + +
- +
-

금 현물

-
N/A
-
N/A
+

KRX 금현물

+
0 원
+
0%
-

비트코인

-
N/A
-
N/A
+

업비트 BTC

+
0 원
+
0%

총 손익

-
N/A
-
N/A
+
0 원
+
0%
-
-
-

📊 자산 현황

- -
- -
- +
+ +
+
- - + + - - - + + + + - +
항목전일종가종목전일종가 현재가 변동 변동률평단가보유량매입액평단가보유수량매수금액업데이트
+
- -