Very Good Web Server Work
This commit is contained in:
8
asset_pilot_docker/.venv/bin/httpx
Executable file
8
asset_pilot_docker/.venv/bin/httpx
Executable file
@@ -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())
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,113 +1,200 @@
|
|||||||
import requests
|
import httpx
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from typing import Dict, Optional
|
|
||||||
import time
|
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:
|
class DataFetcher:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.session = requests.Session()
|
# 인베스팅닷컴은 헤더가 없으면 403 에러를 뱉습니다. 브라우저와 동일하게 설정합니다.
|
||||||
self.session.headers.update({
|
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'
|
'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:
|
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":
|
if asset_code == "USD/DXY":
|
||||||
url = "https://www.investing.com/indices/usdollar"
|
url = "https://www.investing.com/indices/usdollar"
|
||||||
|
# elif asset_code == "USD/KRW":
|
||||||
|
# url = "https://kr.investing.com/currencies/usd-krw"
|
||||||
|
else:
|
||||||
|
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
|
||||||
|
|
||||||
|
# 인베스팅은 쿠키와 리다이렉트가 중요하므로 follow_redirects 사용
|
||||||
|
response = await client.get(url, timeout=15, follow_redirects=True)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"⚠️ Investing 응답 에러 ({asset_code}): {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
# allow_redirects를 True로 하여 주소 변경에 대응
|
|
||||||
response = self.session.get(url, timeout=10, allow_redirects=True)
|
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|
||||||
# 윈도우에서 가장 잘 되던 패턴 순서대로 시도
|
# 인베스팅닷컴의 다양한 HTML 구조에 대응하는 정규식 (우선순위 순)
|
||||||
patterns = [
|
patterns = [
|
||||||
r'data-test="instrument-price-last">([\d,.]+)<',
|
r'data-test="instrument-price-last">([\d,.]+)<', # 최신 메인 패턴
|
||||||
r'last_last">([\d,.]+)<',
|
r'last_last">([\d,.]+)<', # 구형/세부 페이지 패턴
|
||||||
r'instrument-price-last">([\d,.]+)<'
|
r'instrument-price-last">([\d,.]+)<', # 클래식 패턴
|
||||||
|
r'class="[^"]*text-2xl[^"]*">([\d,.]+)<' # 비상용 패턴
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
p = re.search(pattern, html)
|
p = re.search(pattern, html)
|
||||||
if p:
|
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:
|
except Exception as e:
|
||||||
print(f"⚠️ Investing 수집 실패 ({asset_code}): {e}")
|
print(f"⚠️ Investing 시스템 에러 ({asset_code}): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_binance(self) -> Optional[float]:
|
async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]:
|
||||||
"""바이낸스 BTC/USDT (보내주신 윈도우 코드 로직)"""
|
"""바이낸스 BTC/USDT"""
|
||||||
url = "https://api.binance.com/api/v3/ticker/price"
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
|
url = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSD"
|
||||||
response.raise_for_status()
|
res = await client.get(url, timeout=5)
|
||||||
return float(response.json()["price"])
|
return float(res.json()["price"])
|
||||||
except Exception as e:
|
except: return None
|
||||||
print(f"❌ Binance API 실패: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fetch_upbit(self) -> Optional[float]:
|
async def fetch_upbit(self, client: httpx.AsyncClient) -> Optional[float]:
|
||||||
"""업비트 BTC/KRW (보내주신 윈도우 코드 로직)"""
|
"""업비트 BTC/KRW"""
|
||||||
url = "https://api.upbit.com/v1/ticker"
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, params={"markets": "KRW-BTC"}, timeout=5)
|
url = "https://api.upbit.com/v1/ticker?markets=KRW-BTC"
|
||||||
response.raise_for_status()
|
res = await client.get(url, timeout=5)
|
||||||
data = response.json()
|
return float(res.json()[0]["trade_price"])
|
||||||
return float(data[0]["trade_price"]) if data else None
|
except: return None
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Upbit API 실패: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fetch_usd_krw(self) -> Optional[float]:
|
async def fetch_krx_gold(self, client: httpx.AsyncClient) -> 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]:
|
|
||||||
"""금 시세 (네이버 금융 모바일)"""
|
|
||||||
try:
|
try:
|
||||||
url = "https://m.stock.naver.com/marketindex/metals/M04020000"
|
url = "https://m.stock.naver.com/marketindex/metals/M04020000"
|
||||||
res = requests.get(url, timeout=5)
|
res = await client.get(url, timeout=5)
|
||||||
m = re.search(r'\"closePrice\":\"([\d,]+)\"', res.text)
|
m = re.search(r'\"closePrice\":\"([\d,.]+)\"', res.text)
|
||||||
return float(m.group(1).replace(",", "")) if m else None
|
if m:
|
||||||
except:
|
return float(m.group(1).replace(",", ""))
|
||||||
return None
|
except: return None
|
||||||
|
|
||||||
def fetch_all(self) -> Dict[str, Dict]:
|
async def update_closing_prices(self, db: Session):
|
||||||
print(f"📊 [{time.strftime('%H:%M:%S')}] 수집 시작...")
|
"""매일 아침 기준가를 스냅샷 찍어 메모리에 저장"""
|
||||||
|
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}")
|
||||||
|
|
||||||
# 1. 환율 먼저 수집 (계산의 핵심)
|
async def update_realtime_prices(self, db: Session) -> Dict:
|
||||||
usd_krw = self.fetch_usd_krw()
|
"""[핵심] 비동기 수집 후 DB 즉시 업데이트"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
# 2. 나머지 자산 수집
|
async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client:
|
||||||
results = {
|
# 1. 병렬 수집 태스크 정의 (USD/KRW도 인베스팅닷컴 함수 사용)
|
||||||
"XAU/USD": {"가격": self.fetch_investing_com("XAU/USD"), "단위": "USD/oz"},
|
tasks = {
|
||||||
"XAU/CNY": {"가격": self.fetch_investing_com("XAU/CNY"), "단위": "CNY/oz"},
|
"XAU/USD": self.fetch_investing_com(client, "XAU/USD"),
|
||||||
"XAU/GBP": {"가격": self.fetch_investing_com("XAU/GBP"), "단위": "GBP/oz"},
|
"XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"),
|
||||||
"USD/DXY": {"가격": self.fetch_investing_com("USD/DXY"), "단위": "Index"},
|
"XAU/GBP": self.fetch_investing_com(client, "XAU/GBP"),
|
||||||
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
|
"USD/DXY": self.fetch_investing_com(client, "USD/DXY"),
|
||||||
"BTC/USD": {"가격": self.fetch_binance(), "단위": "USDT"},
|
"USD/KRW": self.fetch_google_finance(client, "USD/KRW"),
|
||||||
"BTC/KRW": {"가격": self.fetch_upbit(), "단위": "KRW"},
|
"BTC/USD": self.fetch_binance(client),
|
||||||
"KRX/GLD": {"가격": self.fetch_krx_gold(), "단위": "KRW/g"},
|
"BTC/KRW": self.fetch_upbit(client),
|
||||||
|
"KRX/GLD": self.fetch_krx_gold(client),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. XAU/KRW 계산
|
keys = list(tasks.keys())
|
||||||
xau_krw = None
|
values = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||||
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)")
|
raw_results = {}
|
||||||
return 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()
|
fetcher = DataFetcher()
|
||||||
@@ -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()
|
|
||||||
@@ -15,6 +15,12 @@ class Asset(Base):
|
|||||||
category = Column(String(50)) # 귀금속, 암호화폐, 환율 등
|
category = Column(String(50)) # 귀금속, 암호화폐, 환율 등
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
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")
|
user_assets = relationship("UserAsset", back_populates="asset")
|
||||||
price_history = relationship("PriceHistory", back_populates="asset")
|
price_history = relationship("PriceHistory", back_populates="asset")
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ services:
|
|||||||
POSTGRES_USER: asset_user
|
POSTGRES_USER: asset_user
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-assetpilot}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-assetpilot}
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
|
||||||
|
TZ: Asia/Seoul
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./init-db:/docker-entrypoint-initdb.d
|
- ./init-db:/docker-entrypoint-initdb.d
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
networks:
|
networks:
|
||||||
@@ -27,7 +29,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# DNS 설정 (빌드 밖으로 이동)
|
# 📌 command를 여기(app 서비스 내부)에 넣었습니다.
|
||||||
|
command: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
dns:
|
dns:
|
||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
@@ -37,18 +40,21 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
# DB 비밀번호 기본값을 postgres 서비스와 동일하게 'assetpilot'으로 설정
|
|
||||||
DATABASE_URL: postgresql://asset_user:${DB_PASSWORD:-assetpilot}@postgres:5432/asset_pilot
|
DATABASE_URL: postgresql://asset_user:${DB_PASSWORD:-assetpilot}@postgres:5432/asset_pilot
|
||||||
APP_HOST: 0.0.0.0
|
APP_HOST: 0.0.0.0
|
||||||
APP_PORT: 8000
|
APP_PORT: 8000
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
FETCH_INTERVAL: 5
|
FETCH_INTERVAL: 5
|
||||||
|
TZ: Asia/Seoul
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
networks:
|
networks:
|
||||||
- asset_pilot_network
|
- asset_pilot_network
|
||||||
volumes:
|
volumes:
|
||||||
|
- .:/app
|
||||||
- app_logs:/app/logs
|
- app_logs:/app/logs
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -1,37 +1,60 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
import httpx
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
# [변경] 비동기용 스케줄러로 교체
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from dotenv import load_dotenv
|
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.models import Base, Asset, UserAsset, AlertSetting
|
||||||
from app.fetcher import fetcher
|
from app.fetcher import fetcher
|
||||||
from app.calculator import Calculator
|
from app.calculator import Calculator
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# 테이블 생성
|
# 데이터베이스 테이블 생성
|
||||||
Base.metadata.create_all(bind=engine)
|
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__))
|
||||||
|
|
||||||
# 정적 파일 및 템플릿 설정
|
# 2. Static 파일 경로 설정 (절대 경로 사용)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
static_path = os.path.join(BASE_DIR, "static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
if os.path.exists(static_path):
|
||||||
|
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||||
|
print(f"✅ Static 마운트 성공: {static_path}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Static 폴더를 찾을 수 없습니다: {static_path}")
|
||||||
|
|
||||||
# 전역 변수: 현재 가격 캐시
|
# 3. 템플릿 설정 (절대 경로 사용)
|
||||||
current_prices: Dict = {}
|
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 모델 ====================
|
# ==================== Pydantic 모델 ====================
|
||||||
|
|
||||||
class UserAssetUpdate(BaseModel):
|
class UserAssetUpdate(BaseModel):
|
||||||
symbol: str
|
symbol: str
|
||||||
previous_close: float
|
previous_close: float
|
||||||
@@ -41,228 +64,244 @@ class UserAssetUpdate(BaseModel):
|
|||||||
class AlertSettingUpdate(BaseModel):
|
class AlertSettingUpdate(BaseModel):
|
||||||
settings: Dict
|
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):
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
"""자산 마스터 데이터 초기화"""
|
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ==================== DB 초기화 ====================
|
||||||
|
def init_db_data():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
assets_data = [
|
assets_data = [
|
||||||
("XAU/USD", "금/달러", "귀금속"),
|
("XAU/USD", "금/달러", "귀금속"), ("XAU/CNY", "금/위안", "귀금속"),
|
||||||
("XAU/CNY", "금/위안", "귀금속"),
|
("XAU/GBP", "금/파운드", "귀금속"), ("USD/DXY", "달러인덱스", "환율"),
|
||||||
("XAU/GBP", "금/파운드", "귀금속"),
|
("USD/KRW", "달러/원", "환율"), ("BTC/USD", "비트코인/달러", "암호화폐"),
|
||||||
("USD/DXY", "달러인덱스", "환율"),
|
("BTC/KRW", "비트코인/원", "암호화폐"), ("KRX/GLD", "금 현물", "귀금속"),
|
||||||
("USD/KRW", "달러/원", "환율"),
|
|
||||||
("BTC/USD", "비트코인/달러", "암호화폐"),
|
|
||||||
("BTC/KRW", "비트코인/원", "암호화폐"),
|
|
||||||
("KRX/GLD", "금 현물", "귀금속"),
|
|
||||||
("XAU/KRW", "금/원", "귀금속"),
|
("XAU/KRW", "금/원", "귀금속"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for symbol, name, category in assets_data:
|
for symbol, name, category in assets_data:
|
||||||
existing = db.query(Asset).filter(Asset.symbol == symbol).first()
|
if not db.query(Asset).filter(Asset.symbol == symbol).first():
|
||||||
if not existing:
|
db.add(Asset(symbol=symbol, name=name, category=category))
|
||||||
asset = Asset(symbol=symbol, name=name, category=category)
|
|
||||||
db.add(asset)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print("✅ 자산 마스터 데이터 초기화 완료")
|
|
||||||
|
|
||||||
def init_user_assets(db: Session):
|
|
||||||
"""사용자 자산 초기화 (기본값 0)"""
|
|
||||||
assets = db.query(Asset).all()
|
assets = db.query(Asset).all()
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
existing = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
|
if not db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first():
|
||||||
if not existing:
|
db.add(UserAsset(asset_id=asset.id))
|
||||||
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 = {
|
default_settings = {
|
||||||
"급등락_감지": False,
|
"급등락_감지": False, "급등락_임계값": 3.0,
|
||||||
"급등락_임계값": 3.0,
|
"목표수익률_감지": False, "목표수익률": 10.0,
|
||||||
"목표수익률_감지": False,
|
"특정가격_감지": False, "금_목표가격": 100000, "BTC_목표가격": 100000000,
|
||||||
"목표수익률": 10.0,
|
|
||||||
"특정가격_감지": False,
|
|
||||||
"금_목표가격": 100000,
|
|
||||||
"BTC_목표가격": 100000000,
|
|
||||||
}
|
}
|
||||||
|
for key, val in default_settings.items():
|
||||||
for key, value in default_settings.items():
|
if not db.query(AlertSetting).filter(AlertSetting.setting_key == key).first():
|
||||||
existing = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
|
db.add(AlertSetting(setting_key=key, setting_value=json.dumps(val)))
|
||||||
if not existing:
|
|
||||||
setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
|
|
||||||
db.add(setting)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print("✅ 알림 설정 초기화 완료")
|
|
||||||
|
|
||||||
# ==================== 앱 시작 이벤트 ====================
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""앱 시작 시 초기화 및 백그라운드 작업 시작"""
|
|
||||||
db = next(get_db())
|
|
||||||
try:
|
|
||||||
init_assets(db)
|
|
||||||
init_user_assets(db)
|
|
||||||
init_alert_settings(db)
|
|
||||||
print("🚀 Asset Pilot 서버 시작 완료")
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# 백그라운드 데이터 수집 시작
|
# ==================== 백그라운드 태스크 (Watchdog & 알림 통합) ====================
|
||||||
asyncio.create_task(background_fetch())
|
|
||||||
|
|
||||||
async def background_fetch():
|
async def background_fetch():
|
||||||
"""백그라운드에서 주기적으로 가격 수집"""
|
"""비동기 수집 루프: DB 업데이트 + Heartbeat + 알림"""
|
||||||
global current_prices
|
|
||||||
interval = int(os.getenv('FETCH_INTERVAL', 5))
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 데이터 수집 시작...")
|
async with clients_lock:
|
||||||
current_prices = fetcher.fetch_all()
|
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"<b>[{icon}] {symbol}</b>\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"<b>💰 수익 목표달성! ({symbol})</b>\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"<b>✨ 금 목표가 돌파!</b>\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"<b>₿ BTC 목표가 돌파!</b>\n현재가: {price:,.0f}원")
|
||||||
|
last_alert_time["btc_hit"] = now_ts
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 데이터 수집 오류: {e}")
|
system_status["status"] = "error"
|
||||||
|
print(f"❌ 수집 루프 에러: {e}")
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def read_root(request: Request):
|
async def read_root(request: Request):
|
||||||
"""메인 페이지"""
|
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
@app.get("/api/prices")
|
@app.get("/api/prices")
|
||||||
async def get_prices():
|
async def get_prices(db: Session = Depends(get_db)):
|
||||||
"""현재 가격 조회"""
|
"""[개선] 데이터 신선도 상태와 서버 시각을 포함하여 반환"""
|
||||||
return current_prices
|
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")
|
@app.get("/api/assets")
|
||||||
async def get_assets(db: Session = Depends(get_db)):
|
async def get_assets(db: Session = Depends(get_db)):
|
||||||
"""사용자 자산 조회"""
|
assets = db.query(Asset, UserAsset).join(UserAsset).all()
|
||||||
assets = db.query(Asset, UserAsset).join(
|
return [{
|
||||||
UserAsset, Asset.id == UserAsset.asset_id
|
"symbol": a.symbol, "name": a.name, "category": a.category,
|
||||||
).all()
|
"previous_close": float(ua.previous_close),
|
||||||
|
"average_price": float(ua.average_price),
|
||||||
result = []
|
"quantity": float(ua.quantity)
|
||||||
for asset, user_asset in assets:
|
} for a, ua 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="사용자 자산 정보를 찾을 수 없습니다")
|
|
||||||
|
|
||||||
@app.get("/api/pnl")
|
@app.get("/api/pnl")
|
||||||
async def get_pnl(db: Session = Depends(get_db)):
|
async def get_pnl(db: Session = Depends(get_db)):
|
||||||
"""손익 계산"""
|
krx = db.query(Asset, UserAsset).join(UserAsset).filter(Asset.symbol == "KRX/GLD").first()
|
||||||
# KRX/GLD 자산 정보
|
btc = db.query(Asset, UserAsset).join(UserAsset).filter(Asset.symbol == "BTC/KRW").first()
|
||||||
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("가격")
|
|
||||||
|
|
||||||
pnl = Calculator.calc_pnl(
|
pnl = Calculator.calc_pnl(
|
||||||
gold_buy_price, gold_quantity,
|
float(krx[1].average_price) if krx else 0, float(krx[1].quantity) if krx else 0,
|
||||||
btc_buy_price, btc_quantity,
|
float(btc[1].average_price) if btc else 0, float(btc[1].quantity) if btc else 0,
|
||||||
current_gold, current_btc
|
krx[0].current_price if krx else 0, btc[0].current_price if btc else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
return pnl
|
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")
|
@app.get("/api/alerts/settings")
|
||||||
async def get_alert_settings(db: Session = Depends(get_db)):
|
async def get_alert_settings(db: Session = Depends(get_db)):
|
||||||
"""알림 설정 조회"""
|
|
||||||
settings = db.query(AlertSetting).all()
|
settings = db.query(AlertSetting).all()
|
||||||
result = {}
|
return {s.setting_key: json.loads(s.setting_value) for s in settings}
|
||||||
for setting in settings:
|
|
||||||
try:
|
|
||||||
result[setting.setting_key] = json.loads(setting.setting_value)
|
|
||||||
except:
|
|
||||||
result[setting.setting_key] = setting.setting_value
|
|
||||||
return result
|
|
||||||
|
|
||||||
@app.post("/api/alerts/settings")
|
@app.post("/api/alerts/settings")
|
||||||
async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(get_db)):
|
async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(get_db)):
|
||||||
"""알림 설정 업데이트"""
|
|
||||||
for key, value in data.settings.items():
|
for key, value in data.settings.items():
|
||||||
setting = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
|
s = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
|
||||||
if setting:
|
if s: s.setting_value = json.dumps(value)
|
||||||
setting.setting_value = json.dumps(value)
|
|
||||||
else:
|
|
||||||
new_setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
|
|
||||||
db.add(new_setting)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "success", "message": "알림 설정 업데이트 완료"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""헬스 체크"""
|
return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]}
|
||||||
return {
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"prices_loaded": len(current_prices) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================== 메인 실행 ====================
|
# if __name__ == "__main__":
|
||||||
|
# import uvicorn
|
||||||
if __name__ == "__main__":
|
# uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
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"
|
|
||||||
)
|
|
||||||
@@ -10,3 +10,5 @@ requests==2.31.0
|
|||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
lxml==4.9.3
|
lxml==4.9.3
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
apscheduler==3.9.1
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -5,349 +9,709 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* 핵심 색상 변수 - app.js profit/loss 연동 보존 */
|
||||||
--primary-color: #2563eb;
|
--primary-color: #2563eb;
|
||||||
--success-color: #10b981;
|
|
||||||
--danger-color: #ef4444;
|
--danger-color: #ef4444;
|
||||||
--warning-color: #f59e0b;
|
--success-color: #10b981;
|
||||||
--bg-color: #f8fafc;
|
|
||||||
--card-bg: #ffffff;
|
/* 테마 */
|
||||||
--text-primary: #1e293b;
|
--bg-color: #161c2a;
|
||||||
--text-secondary: #64748b;
|
--bg-secondary: #1e2636;
|
||||||
--border-color: #e2e8f0;
|
--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 {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: var(--font-body);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
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 {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 헤더 */
|
/* ─── 손익 색상 (app.js profit/loss 연동 - 절대 수정 금지) ─ */
|
||||||
header {
|
.profit { color: var(--danger-color) !important; }
|
||||||
background: var(--card-bg);
|
.loss { color: var(--primary-color) !important; }
|
||||||
border-radius: 12px;
|
.numeric.profit, .numeric.loss { font-weight: 600; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ─── 상태바 ─────────────────────────────────────────── */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
background: #111827;
|
||||||
font-size: 14px;
|
color: #8892aa;
|
||||||
color: var(--text-secondary);
|
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 {
|
.status-bar .status-dot {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
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;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.status-stale {
|
||||||
0%, 100% { opacity: 1; }
|
background-color: var(--danger-color) !important;
|
||||||
50% { opacity: 0.5; }
|
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 {
|
.pnl-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-card {
|
.pnl-card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-radius: 12px;
|
border: 1px solid var(--card-border);
|
||||||
padding: 20px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
padding: 18px 22px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-card.total {
|
.pnl-card:hover {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, #1e40af 100%);
|
transform: translateY(-2px);
|
||||||
color: white;
|
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-card h3 {
|
.pnl-card h3 {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 12px;
|
color: var(--text-muted);
|
||||||
opacity: 0.9;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-value {
|
.pnl-value {
|
||||||
font-size: 28px;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-size: clamp(18px, 3vw, 26px);
|
||||||
|
font-weight: 600;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-percent {
|
.pnl-percent {
|
||||||
font-size: 16px;
|
font-family: var(--font-mono);
|
||||||
font-weight: 500;
|
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 {
|
.pnl-card.total h3 { color: rgba(255,255,255,0.6); }
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pnl-card.total .pnl-value,
|
.pnl-card.total .pnl-value,
|
||||||
.pnl-card.total .pnl-percent {
|
.pnl-card.total .pnl-percent,
|
||||||
color: white;
|
.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);
|
background: var(--card-bg);
|
||||||
border-radius: 12px;
|
border: 1px solid var(--card-border);
|
||||||
padding: 24px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
overflow: hidden;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header h2 {
|
.table-section-header {
|
||||||
font-size: 20px;
|
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;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
min-width: 720px; /* 가로 스크롤 기준점 */
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
thead tr {
|
||||||
padding: 12px 16px;
|
background: rgba(0,0,0,0.2);
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: var(--bg-color);
|
padding: 11px 16px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
color: var(--text-muted);
|
||||||
color: var(--text-secondary);
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
|
padding: 13px 16px;
|
||||||
font-size: 14px;
|
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 {
|
.numeric {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 입력창 컬럼: 내용에 맞게 최소 너비 고정 */
|
||||||
|
td.input-cell {
|
||||||
|
width: 1%; /* shrink-to-fit */
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
td input {
|
td input {
|
||||||
width: 100%;
|
width: clamp(72px, 8vw, 120px); /* 화면 너비 비례, 최소 72 최대 120 */
|
||||||
padding: 6px 8px;
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
font-size: 14px;
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: right;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
td input:focus {
|
td input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37,99,235,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-up {
|
.premium-row td { background-color: rgba(245, 158, 11, 0.04) !important; }
|
||||||
color: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-down {
|
/* ─── 버튼 ────────────────────────────────────────── */
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 버튼 */
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 9px 18px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-family: var(--font-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover { background: #1e40af; transform: translateY(-1px); }
|
||||||
background-color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: var(--border-color);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover { background: var(--border-color); }
|
||||||
background-color: #cbd5e1;
|
|
||||||
|
.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 {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 9999;
|
||||||
left: 0;
|
left: 0; top: 0;
|
||||||
top: 0;
|
width: 100vw; height: 100vh;
|
||||||
width: 100%;
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
height: 100%;
|
backdrop-filter: blur(4px);
|
||||||
background-color: rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.active {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal.active { display: flex !important; }
|
||||||
background-color: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
.modal .modal-content, .modal-content.card {
|
||||||
width: 90%;
|
background: var(--card-bg) !important;
|
||||||
max-width: 600px;
|
border: 1px solid var(--card-border) !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
width: 90% !important;
|
||||||
|
max-width: 460px !important;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-content.card > .setting-group label {
|
||||||
font-size: 20px;
|
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 {
|
.close {
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
font-weight: 300;
|
|
||||||
cursor: pointer;
|
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 {
|
.close:hover {
|
||||||
color: var(--text-primary);
|
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 {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px;
|
padding: 28px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer p {
|
/* ─── 반응형 ───────────────────────────────────────── */
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 */
|
/* 태블릿 (768px ~ 1024px) */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.container {
|
.container { padding: 16px; }
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pnl-summary {
|
.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;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
/* 손익 카드: 모바일 2+1 레이아웃 */
|
||||||
padding: 8px;
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +1,245 @@
|
|||||||
// 전역 변수
|
/**
|
||||||
|
* Asset Pilot - Frontend Logic (Integrated Watchdog Version)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 전역 변수 - 선임님 기존 변수명 유지
|
||||||
let currentPrices = {};
|
let currentPrices = {};
|
||||||
let userAssets = [];
|
let userAssets = [];
|
||||||
let alertSettings = {};
|
let alertSettings = {};
|
||||||
|
let serverTimeOffset = 0;
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
|
||||||
// 초기화
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadAssets();
|
|
||||||
loadAlertSettings();
|
loadAlertSettings();
|
||||||
startPriceStream();
|
startPriceStream();
|
||||||
|
|
||||||
// 이벤트 리스너
|
|
||||||
document.getElementById('refresh-btn').addEventListener('click', refreshData);
|
document.getElementById('refresh-btn').addEventListener('click', refreshData);
|
||||||
document.getElementById('alert-settings-btn').addEventListener('click', openAlertModal);
|
document.getElementById('alert-settings-btn').addEventListener('click', openAlertModal);
|
||||||
document.querySelector('.close').addEventListener('click', closeAlertModal);
|
document.querySelector('.close').addEventListener('click', closeAlertModal);
|
||||||
document.getElementById('save-alerts').addEventListener('click', saveAlertSettings);
|
document.getElementById('save-alerts').addEventListener('click', saveAlertSettings);
|
||||||
document.getElementById('cancel-alerts').addEventListener('click', closeAlertModal);
|
document.getElementById('cancel-alerts').addEventListener('click', closeAlertModal);
|
||||||
|
|
||||||
// 주기적 PnL 업데이트
|
setInterval(checkDataFreshness, 1000);
|
||||||
setInterval(updatePnL, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 자산 데이터 로드
|
async function loadInitialPrices() {
|
||||||
async function loadAssets() {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/assets');
|
const response = await fetch('/api/prices');
|
||||||
userAssets = await response.json();
|
if (response.ok) {
|
||||||
renderAssetsTable();
|
const result = await response.json();
|
||||||
} catch (error) {
|
processPriceData(result);
|
||||||
console.error('자산 로드 실패:', error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) { console.error('초기 가격 로드 실패:', error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 알림 설정 로드
|
function processPriceData(result) {
|
||||||
async function loadAlertSettings() {
|
currentPrices = result.prices || result;
|
||||||
try {
|
|
||||||
const response = await fetch('/api/alerts/settings');
|
if (result.fetch_status) {
|
||||||
alertSettings = await response.json();
|
updateSystemStatus(result.fetch_status, result.last_heartbeat, result.server_time);
|
||||||
} catch (error) {
|
|
||||||
console.error('알림 설정 로드 실패:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePricesInTable();
|
||||||
|
calculatePnLRealtime();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실시간 가격 스트리밍
|
|
||||||
function startPriceStream() {
|
function startPriceStream() {
|
||||||
const eventSource = new EventSource('/api/stream');
|
const eventSource = new EventSource('/api/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
currentPrices = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
currentPrices = data;
|
||||||
updatePricesInTable();
|
updatePricesInTable();
|
||||||
updateLastUpdateTime();
|
calculatePnLRealtime();
|
||||||
|
document.getElementById('status-indicator').style.backgroundColor = '#10b981';
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
console.error('SSE 연결 오류');
|
|
||||||
document.getElementById('status-indicator').style.backgroundColor = '#ef4444';
|
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 = `<span style="color: #ef4444; font-weight:bold;">⚠️ ${timeStr}</span>`;
|
||||||
|
} 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() {
|
function renderAssetsTable() {
|
||||||
const tbody = document.getElementById('assets-tbody');
|
const tbody = document.getElementById('assets-tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
const assets = [
|
ASSET_SYMBOLS.forEach(symbol => {
|
||||||
'XAU/USD', 'XAU/CNY', 'XAU/GBP', 'USD/DXY', 'USD/KRW',
|
const asset = userAssets.find(a => a.symbol === symbol) || {
|
||||||
'BTC/USD', 'BTC/KRW', 'KRX/GLD', 'XAU/KRW'
|
symbol: symbol, previous_close: 0, average_price: 0, quantity: 0
|
||||||
];
|
};
|
||||||
|
|
||||||
assets.forEach(symbol => {
|
|
||||||
const asset = userAssets.find(a => a.symbol === symbol);
|
|
||||||
if (!asset) return;
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.dataset.symbol = symbol;
|
row.dataset.symbol = symbol;
|
||||||
|
|
||||||
const decimalPlaces = symbol.includes('BTC') ? 8 : 2;
|
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td><strong>${symbol}</strong></td>
|
<td><a href="${getInvestingUrl(symbol)}" target="_blank" class="investing-btn">${symbol}</a></td>
|
||||||
<td class="numeric">
|
<td class="numeric input-cell"><input type="number" class="prev-close" value="${asset.previous_close}" step="0.01" data-symbol="${symbol}"></td>
|
||||||
<input type="number"
|
<td class="numeric current-price">Loading...</td>
|
||||||
class="prev-close"
|
<td class="numeric change">0</td>
|
||||||
value="${asset.previous_close}"
|
<td class="numeric change-percent">0%</td>
|
||||||
step="0.01"
|
<td class="numeric input-cell"><input type="number" class="avg-price" value="${asset.average_price}" step="0.01" data-symbol="${symbol}"></td>
|
||||||
data-symbol="${symbol}">
|
<td class="numeric input-cell"><input type="number" class="quantity" value="${asset.quantity}" step="${symbol.includes('BTC') ? '0.00000001' : '0.01'}" data-symbol="${symbol}"></td>
|
||||||
</td>
|
|
||||||
<td class="numeric current-price">N/A</td>
|
|
||||||
<td class="numeric change">N/A</td>
|
|
||||||
<td class="numeric change-percent">N/A</td>
|
|
||||||
<td class="numeric">
|
|
||||||
<input type="number"
|
|
||||||
class="avg-price"
|
|
||||||
value="${asset.average_price}"
|
|
||||||
step="0.01"
|
|
||||||
data-symbol="${symbol}">
|
|
||||||
</td>
|
|
||||||
<td class="numeric">
|
|
||||||
<input type="number"
|
|
||||||
class="quantity"
|
|
||||||
value="${asset.quantity}"
|
|
||||||
step="${symbol.includes('BTC') ? '0.00000001' : '0.01'}"
|
|
||||||
data-symbol="${symbol}">
|
|
||||||
</td>
|
|
||||||
<td class="numeric buy-total">0</td>
|
<td class="numeric buy-total">0</td>
|
||||||
|
<td class="numeric update-time-cell" style="font-size: 0.85em; color: #666;">-</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(row);
|
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('#assets-tbody input').forEach(input => {
|
||||||
document.querySelectorAll('input[type="number"]').forEach(input => {
|
input.addEventListener('change', (e) => {
|
||||||
input.addEventListener('change', handleAssetChange);
|
handleAssetChange(e);
|
||||||
input.addEventListener('blur', handleAssetChange);
|
updatePricesInTable();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블에 가격 업데이트
|
|
||||||
function 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 => {
|
rows.forEach(row => {
|
||||||
const symbol = row.dataset.symbol;
|
const symbol = row.dataset.symbol;
|
||||||
const priceData = currentPrices[symbol];
|
const priceData = currentPrices[symbol];
|
||||||
|
if (!priceData || !priceData.가격) return;
|
||||||
if (!priceData || !priceData.가격) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPrice = priceData.가격;
|
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에 실시간 주입
|
||||||
const decimalPlaces = symbol.includes('USD') || symbol.includes('DXY') ? 2 : 0;
|
if (symbol === 'XAU/KRW' && currentPrices['KRX/GLD']) {
|
||||||
row.querySelector('.current-price').textContent = formatNumber(currentPrice, decimalPlaces);
|
const prevCloseInput = row.querySelector('.prev-close');
|
||||||
|
setRawValue(prevCloseInput, currentPrices['KRX/GLD'].가격);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevCloseInput = row.querySelector('.prev-close');
|
||||||
|
const prevClose = getRawValue(prevCloseInput);
|
||||||
|
|
||||||
|
const currentPriceCell = row.querySelector('.current-price');
|
||||||
|
currentPriceCell.textContent = formatNumber(currentPrice, decimalPlaces);
|
||||||
|
|
||||||
// 변동 계산
|
|
||||||
const change = currentPrice - prevClose;
|
const change = currentPrice - prevClose;
|
||||||
const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0;
|
const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0;
|
||||||
|
|
||||||
@@ -144,90 +249,106 @@ function updatePricesInTable() {
|
|||||||
changeCell.textContent = formatNumber(change, decimalPlaces);
|
changeCell.textContent = formatNumber(change, decimalPlaces);
|
||||||
changePercentCell.textContent = `${formatNumber(changePercent, 2)}%`;
|
changePercentCell.textContent = `${formatNumber(changePercent, 2)}%`;
|
||||||
|
|
||||||
// 색상 적용
|
// [교정] 선임님 기존 클래스명 profit/loss로 정확히 변경
|
||||||
const colorClass = change > 0 ? 'price-up' : change < 0 ? 'price-down' : '';
|
const cellsToColor = [currentPriceCell, changeCell, changePercentCell];
|
||||||
changeCell.className = `numeric ${colorClass}`;
|
cellsToColor.forEach(cell => {
|
||||||
changePercentCell.className = `numeric ${colorClass}`;
|
cell.classList.remove('profit', 'loss');
|
||||||
|
if (prevClose > 0) {
|
||||||
// 매입액 계산
|
if (change > 0) cell.classList.add('profit');
|
||||||
const avgPrice = parseFloat(row.querySelector('.avg-price').value) || 0;
|
else if (change < 0) cell.classList.add('loss');
|
||||||
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
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
const avgPrice = getRawValue(row.querySelector('.avg-price'));
|
||||||
console.log(`✅ ${symbol} 업데이트 완료`);
|
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
|
||||||
// 매입액 즉시 업데이트
|
row.querySelector('.buy-total').textContent = formatNumber(avgPrice * quantity, 0);
|
||||||
const buyTotal = averagePrice * quantity;
|
});
|
||||||
row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 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 = `<td style="color:#6366f1;font-weight:bold;background:#f8fafc;">${label}</td><td class="numeric" style="background:#f8fafc;">-</td><td class="numeric premium-value" style="font-weight:bold;background:#f8fafc;">계산중...</td><td class="numeric premium-diff" colspan="2" style="background:#f8fafc;font-weight:bold;">-</td><td colspan="4" style="background:#f8fafc;"></td>`;
|
||||||
|
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() {
|
function openAlertModal() {
|
||||||
document.getElementById('급등락_감지').checked = alertSettings.급등락_감지 || false;
|
document.getElementById('급등락_감지').checked = alertSettings.급등락_감지 || false;
|
||||||
document.getElementById('급등락_임계값').value = alertSettings.급등락_임계값 || 3.0;
|
document.getElementById('급등락_임계값').value = alertSettings.급등락_임계값 || 3.0;
|
||||||
@@ -236,74 +357,13 @@ function openAlertModal() {
|
|||||||
document.getElementById('특정가격_감지').checked = alertSettings.특정가격_감지 || false;
|
document.getElementById('특정가격_감지').checked = alertSettings.특정가격_감지 || false;
|
||||||
document.getElementById('금_목표가격').value = alertSettings.금_목표가격 || 100000;
|
document.getElementById('금_목표가격').value = alertSettings.금_목표가격 || 100000;
|
||||||
document.getElementById('BTC_목표가격').value = alertSettings.BTC_목표가격 || 100000000;
|
document.getElementById('BTC_목표가격').value = alertSettings.BTC_목표가격 || 100000000;
|
||||||
|
|
||||||
document.getElementById('alert-modal').classList.add('active');
|
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() {
|
async function saveAlertSettings() {
|
||||||
const settings = {
|
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) };
|
||||||
급등락_감지: document.getElementById('급등락_감지').checked,
|
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); }
|
||||||
급등락_임계값: 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 }); }
|
||||||
async function refreshData() {
|
window.addEventListener('click', (e) => { if (e.target === document.getElementById('alert-modal')) closeAlertModal(); });
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
BIN
asset_pilot_docker/templates/favicon.ico
Normal file
BIN
asset_pilot_docker/templates/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -4,131 +4,138 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Asset Pilot - 자산 모니터</title>
|
<title>Asset Pilot - 자산 모니터</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css?v=2.0">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- 시스템 상태바 -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span id="status-dot" class="status-dot status-healthy"></span>
|
||||||
|
<span id="status-text">시스템 가동 중</span>
|
||||||
|
<span style="flex-grow: 1;"></span>
|
||||||
|
<div style="display: flex; gap: 15px; align-items: center;">
|
||||||
|
<small id="last-sync-time" style="color: #4d566b;"></small>
|
||||||
|
<div id="status-indicator" title="SSE Stream Status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- 헤더 -->
|
||||||
<header>
|
<header>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
<h1>💰 Asset Pilot</h1>
|
<h1>💰 Asset Pilot</h1>
|
||||||
<p class="subtitle">실시간 자산 모니터링 시스템</p>
|
<p class="subtitle">실시간 자산 모니터링 시스템</p>
|
||||||
<div class="status-bar">
|
</div>
|
||||||
<span id="status-indicator" class="status-dot"></span>
|
<div class="header-actions">
|
||||||
<span id="last-update">데이터 수집 중...</span>
|
<button id="alert-settings-btn" class="icon-btn">🔔 알림설정</button>
|
||||||
|
<button id="refresh-btn" class="icon-btn">🔄 새로고침</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- 손익 요약 -->
|
<!-- 손익 요약 카드 -->
|
||||||
<section class="pnl-summary">
|
<section class="pnl-summary">
|
||||||
<div class="pnl-card">
|
<div class="pnl-card">
|
||||||
<h3>금 현물</h3>
|
<h3>KRX 금현물</h3>
|
||||||
<div class="pnl-value" id="gold-pnl">N/A</div>
|
<div class="pnl-value" id="gold-pnl">0 원</div>
|
||||||
<div class="pnl-percent" id="gold-percent">N/A</div>
|
<div class="pnl-percent" id="gold-percent">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pnl-card">
|
<div class="pnl-card">
|
||||||
<h3>비트코인</h3>
|
<h3>업비트 BTC</h3>
|
||||||
<div class="pnl-value" id="btc-pnl">N/A</div>
|
<div class="pnl-value" id="btc-pnl">0 원</div>
|
||||||
<div class="pnl-percent" id="btc-percent">N/A</div>
|
<div class="pnl-percent" id="btc-percent">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pnl-card total">
|
<div class="pnl-card total">
|
||||||
<h3>총 손익</h3>
|
<h3>총 손익</h3>
|
||||||
<div class="pnl-value" id="total-pnl">N/A</div>
|
<div class="pnl-value" id="total-pnl">0 원</div>
|
||||||
<div class="pnl-percent" id="total-percent">N/A</div>
|
<div class="pnl-percent" id="total-percent">0%</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 자산 테이블 -->
|
<!-- 자산 테이블 -->
|
||||||
<section class="assets-section">
|
<section class="table-section">
|
||||||
<div class="section-header">
|
<!-- 테이블 래퍼: 모바일 가로 스크롤 -->
|
||||||
<h2>📊 자산 현황</h2>
|
<div class="table-wrapper">
|
||||||
<button id="refresh-btn" class="btn btn-primary">새로고침</button>
|
<table>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table id="assets-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>항목</th>
|
<th>종목</th>
|
||||||
<th class="numeric">전일종가</th>
|
<th class="numeric input-cell">전일종가</th>
|
||||||
<th class="numeric">현재가</th>
|
<th class="numeric">현재가</th>
|
||||||
<th class="numeric">변동</th>
|
<th class="numeric">변동</th>
|
||||||
<th class="numeric">변동률</th>
|
<th class="numeric">변동률</th>
|
||||||
<th class="numeric">평단가</th>
|
<th class="numeric input-cell">평단가</th>
|
||||||
<th class="numeric">보유량</th>
|
<th class="numeric input-cell">보유수량</th>
|
||||||
<th class="numeric">매입액</th>
|
<th class="numeric">매수금액</th>
|
||||||
|
<th>업데이트</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="assets-tbody">
|
<tbody id="assets-tbody">
|
||||||
<!-- 동적으로 생성 -->
|
<!-- app.js 가 동적으로 채움 -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div><!-- /.container -->
|
||||||
|
|
||||||
<!-- 알림 설정 모달 -->
|
<!-- 알림 설정 모달 -->
|
||||||
<div id="alert-modal" class="modal">
|
<div id="alert-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>🔔 알림 설정</h2>
|
<span class="modal-header h2" style="font-size:17px; font-weight:700;">🔔 알림 및 감시 설정</span>
|
||||||
<span class="close">×</span>
|
<span class="close">×</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>급등/급락 알림</h3>
|
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" id="급등락_감지">
|
<input type="checkbox" id="급등락_감지">
|
||||||
활성화
|
급등락 알림 (전일대비)
|
||||||
</label>
|
<input type="number" id="급등락_임계값" step="0.1" value="3.0" style="margin-left:auto; width:80px; height:30px; padding:0 6px; text-align:right; background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:5px; color:var(--text-primary); font-family:var(--font-mono); font-size:13px;">
|
||||||
<label>
|
<span style="color:var(--text-muted); font-size:12px; white-space:nowrap;">% 이상</span>
|
||||||
변동 임계값:
|
|
||||||
<input type="number" id="급등락_임계값" step="0.5" min="0.5" max="20">
|
|
||||||
%
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>목표 수익률 알림</h3>
|
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" id="목표수익률_감지">
|
<input type="checkbox" id="목표수익률_감지">
|
||||||
활성화
|
목표수익률 알림
|
||||||
</label>
|
<input type="number" id="목표수익률" step="0.5" value="10.0" style="margin-left:auto; width:80px; height:30px; padding:0 6px; text-align:right; background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:5px; color:var(--text-primary); font-family:var(--font-mono); font-size:13px;">
|
||||||
<label>
|
<span style="color:var(--text-muted); font-size:12px; white-space:nowrap;">% 달성 시</span>
|
||||||
목표 수익률:
|
|
||||||
<input type="number" id="목표수익률" step="0.5" min="0.1" max="100">
|
|
||||||
%
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>특정 가격 도달 알림</h3>
|
<label style="margin-bottom:12px;">
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="특정가격_감지">
|
<input type="checkbox" id="특정가격_감지">
|
||||||
활성화
|
특정가격 도달 알림
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
금 목표가:
|
|
||||||
<input type="number" id="금_목표가격" step="1000" min="50000">
|
|
||||||
원
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
BTC 목표가:
|
|
||||||
<input type="number" id="BTC_목표가격" step="1000000" min="50000000">
|
|
||||||
원
|
|
||||||
</label>
|
</label>
|
||||||
|
<div class="sub-setting">
|
||||||
|
<span style="color:var(--text-secondary); font-size:13px; white-space:nowrap;">금(KRX) 목표</span>
|
||||||
|
<input type="number" id="금_목표가격" value="100000">
|
||||||
|
<span style="color:var(--text-muted); font-size:12px;">원</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sub-setting" style="margin-top:8px;">
|
||||||
<div class="modal-footer">
|
<span style="color:var(--text-secondary); font-size:13px; white-space:nowrap;">BTC 목표</span>
|
||||||
<button id="save-alerts" class="btn btn-primary">저장</button>
|
<input type="number" id="BTC_목표가격" value="100000000">
|
||||||
<button id="cancel-alerts" class="btn btn-secondary">취소</button>
|
<span style="color:var(--text-muted); font-size:12px;">원</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
<div class="modal-footer">
|
||||||
<button id="alert-settings-btn" class="btn btn-secondary">⚙️ 알림 설정</button>
|
<button id="cancel-alerts" class="btn btn-secondary">취소</button>
|
||||||
<p>Asset Pilot v1.0 - Orange Pi Edition</p>
|
<button id="save-alerts" class="btn btn-primary">설정 저장</button>
|
||||||
</footer>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user