Very Good Web Server Work

This commit is contained in:
Wind
2026-02-13 18:40:48 +09:00
parent 18fa480c84
commit d4f1ca87ab
12 changed files with 1369 additions and 1352 deletions

View 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())

View File

@@ -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
```

View File

@@ -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()

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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"}
assets_data = [
("XAU/USD", "금/달러", "귀금속"),
("XAU/CNY", "금/위안", "귀금속"),
("XAU/GBP", "금/파운드", "귀금속"),
("USD/DXY", "달러인덱스", "환율"),
("USD/KRW", "달러/원", "환율"),
("BTC/USD", "비트코인/달러", "암호화폐"),
("BTC/KRW", "비트코인/원", "암호화폐"),
("KRX/GLD", "금 현물", "귀금속"),
("XAU/KRW", "금/원", "귀금속"),
]
for symbol, name, category in assets_data: async with httpx.AsyncClient() as client:
existing = db.query(Asset).filter(Asset.symbol == symbol).first() try:
if not existing: resp = await client.post(url, json=payload, timeout=5)
asset = Asset(symbol=symbol, name=name, category=category) if resp.status_code != 200: print(f"❌ 텔레그램 실패: {resp.text}")
db.add(asset) except Exception as e: print(f"❌ 텔레그램 오류: {e}")
db.commit() # ==================== DB 초기화 ====================
print("✅ 자산 마스터 데이터 초기화 완료") def init_db_data():
db = SessionLocal()
def init_user_assets(db: Session):
"""사용자 자산 초기화 (기본값 0)"""
assets = db.query(Asset).all()
for asset in assets:
existing = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
if not existing:
user_asset = UserAsset(
asset_id=asset.id,
previous_close=0,
average_price=0,
quantity=0
)
db.add(user_asset)
db.commit()
print("✅ 사용자 자산 데이터 초기화 완료")
def init_alert_settings(db: Session):
"""알림 설정 초기화"""
default_settings = {
"급등락_감지": False,
"급등락_임계값": 3.0,
"목표수익률_감지": False,
"목표수익률": 10.0,
"특정가격_감지": False,
"금_목표가격": 100000,
"BTC_목표가격": 100000000,
}
for key, value in default_settings.items():
existing = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
if not existing:
setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
db.add(setting)
db.commit()
print("✅ 알림 설정 초기화 완료")
# ==================== 앱 시작 이벤트 ====================
@app.on_event("startup")
async def startup_event():
"""앱 시작 시 초기화 및 백그라운드 작업 시작"""
db = next(get_db())
try: try:
init_assets(db) assets_data = [
init_user_assets(db) ("XAU/USD", "금/달러", "귀금속"), ("XAU/CNY", "금/위안", "귀금속"),
init_alert_settings(db) ("XAU/GBP", "금/파운드", "귀금속"), ("USD/DXY", "달러인덱스", "환율"),
print("🚀 Asset Pilot 서버 시작 완료") ("USD/KRW", "달러/원", "환율"), ("BTC/USD", "비트코인/달러", "암호화폐"),
("BTC/KRW", "비트코인/원", "암호화폐"), ("KRX/GLD", "금 현물", "귀금속"),
("XAU/KRW", "금/원", "귀금속"),
]
for symbol, name, category in assets_data:
if not db.query(Asset).filter(Asset.symbol == symbol).first():
db.add(Asset(symbol=symbol, name=name, category=category))
db.commit()
assets = db.query(Asset).all()
for asset in assets:
if not db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first():
db.add(UserAsset(asset_id=asset.id))
default_settings = {
"급등락_감지": False, "급등락_임계값": 3.0,
"목표수익률_감지": False, "목표수익률": 10.0,
"특정가격_감지": False, "금_목표가격": 100000, "BTC_목표가격": 100000000,
}
for key, val in default_settings.items():
if not db.query(AlertSetting).filter(AlertSetting.setting_key == key).first():
db.add(AlertSetting(setting_key=key, setting_value=json.dumps(val)))
db.commit()
finally: 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"
)

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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();
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -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>
<h1>💰 Asset Pilot</h1> <div style="display: flex; justify-content: space-between; align-items: center;">
<p class="subtitle">실시간 자산 모니터링 시스템</p> <div>
<div class="status-bar"> <h1>💰 Asset Pilot</h1>
<span id="status-indicator" class="status-dot"></span> <p class="subtitle">실시간 자산 모니터링 시스템</p>
<span id="last-update">데이터 수집 중...</span> </div>
<div class="header-actions">
<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 class="modal-content"> <!-- 알림 설정 모달 -->
<div class="modal-header"> <div id="alert-modal" class="modal">
<h2>🔔 알림 설정</h2> <div class="modal-content card">
<span class="close">&times;</span> <div class="modal-header">
<span class="modal-header h2" style="font-size:17px; font-weight:700;">🔔 알림 및 감시 설정</span>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<div class="setting-group">
<label>
<input type="checkbox" id="급등락_감지">
급등락 알림 (전일대비)
<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;">
<span style="color:var(--text-muted); font-size:12px; white-space:nowrap;">% 이상</span>
</label>
</div>
<div class="setting-group">
<label>
<input type="checkbox" id="목표수익률_감지">
목표수익률 알림
<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;">
<span style="color:var(--text-muted); font-size:12px; white-space:nowrap;">% 달성 시</span>
</label>
</div>
<div class="setting-group">
<label style="margin-bottom:12px;">
<input type="checkbox" id="특정가격_감지">
특정가격 도달 알림
</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 class="modal-body"> <div class="sub-setting" style="margin-top:8px;">
<div class="setting-group"> <span style="color:var(--text-secondary); font-size:13px; white-space:nowrap;">BTC 목표</span>
<h3>급등/급락 알림</h3> <input type="number" id="BTC_목표가격" value="100000000">
<label> <span style="color:var(--text-muted); font-size:12px;"></span>
<input type="checkbox" id="급등락_감지">
활성화
</label>
<label>
변동 임계값:
<input type="number" id="급등락_임계값" step="0.5" min="0.5" max="20">
%
</label>
</div>
<div class="setting-group">
<h3>목표 수익률 알림</h3>
<label>
<input type="checkbox" id="목표수익률_감지">
활성화
</label>
<label>
목표 수익률:
<input type="number" id="목표수익률" step="0.5" min="0.1" max="100">
%
</label>
</div>
<div class="setting-group">
<h3>특정 가격 도달 알림</h3>
<label>
<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>
</div>
</div>
<div class="modal-footer">
<button id="save-alerts" class="btn btn-primary">저장</button>
<button id="cancel-alerts" class="btn btn-secondary">취소</button>
</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>