AssetPilot OrangePi 5 Pluse Server-First Commit
This commit is contained in:
227
.TemporaryDocument/DOCKER_QUICKSTART.md
Normal file
227
.TemporaryDocument/DOCKER_QUICKSTART.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# 🐳 Asset Pilot Docker 빠른 시작
|
||||||
|
|
||||||
|
## 1️⃣ 파일 전송 (Orange Pi로)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows PowerShell 또는 Linux/Mac 터미널에서
|
||||||
|
scp asset_pilot_docker.tar.gz orangepi@192.168.1.100:~/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Orange Pi에서 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 접속
|
||||||
|
ssh orangepi@192.168.1.100
|
||||||
|
|
||||||
|
# 압축 해제
|
||||||
|
tar -xzf asset_pilot_docker.tar.gz
|
||||||
|
cd asset_pilot_docker
|
||||||
|
|
||||||
|
# 자동 설치 (Docker가 없으면 자동으로 설치함)
|
||||||
|
bash start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**설치 스크립트가 자동으로 처리:**
|
||||||
|
- ✅ Docker 설치 확인 및 설치
|
||||||
|
- ✅ 비밀번호 설정
|
||||||
|
- ✅ 컨테이너 빌드 및 실행
|
||||||
|
- ✅ 데이터베이스 초기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ 접속
|
||||||
|
|
||||||
|
```
|
||||||
|
http://[Orange_Pi_IP]:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
예: `http://192.168.1.100:8000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Docker 컨테이너 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Orange Pi 5 Plus (Ubuntu) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────┐ │
|
||||||
|
│ │ Docker Network │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌────────────┐ ┌────────────┐ │ │
|
||||||
|
│ │ │ PostgreSQL │ │ App │ │ │
|
||||||
|
│ │ │ Container │ │ Container │ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ Port 5432 │←→│ FastAPI │ │ │
|
||||||
|
│ │ │ │ │ Port 8000 │ │ │
|
||||||
|
│ │ └────────────┘ └────────────┘ │ │
|
||||||
|
│ │ ↓ ↓ │ │
|
||||||
|
│ │ [Volume] [Volume] │ │
|
||||||
|
│ │ postgres_data app_logs │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ ↑ │
|
||||||
|
│ Port 8000 (외부 접근) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 명령어
|
||||||
|
|
||||||
|
### 컨테이너 관리
|
||||||
|
```bash
|
||||||
|
# 전체 시작
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 전체 중지
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 로그 보기 (실시간)
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 특정 컨테이너만 제어
|
||||||
|
```bash
|
||||||
|
# 앱만 재시작
|
||||||
|
docker compose restart app
|
||||||
|
|
||||||
|
# DB만 재시작
|
||||||
|
docker compose restart postgres
|
||||||
|
|
||||||
|
# 앱 로그만 보기
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Windows 데이터 가져오기
|
||||||
|
|
||||||
|
기존 `user_assets.csv` 파일이 있다면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. CSV 파일을 컨테이너로 복사
|
||||||
|
docker cp user_assets.csv asset_pilot_app:/app/
|
||||||
|
|
||||||
|
# 2. 가져오기 실행
|
||||||
|
docker compose exec app python import_csv.py user_assets.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 백업 및 복원
|
||||||
|
|
||||||
|
### 백업 생성
|
||||||
|
```bash
|
||||||
|
# 데이터베이스 백업
|
||||||
|
docker compose exec postgres pg_dump -U asset_user asset_pilot > backup_$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백업 복원
|
||||||
|
```bash
|
||||||
|
# 백업 파일 복원
|
||||||
|
cat backup_20260210.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 문제 해결
|
||||||
|
|
||||||
|
### 컨테이너가 시작되지 않음
|
||||||
|
```bash
|
||||||
|
# 로그 확인
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# 특정 서비스 로그
|
||||||
|
docker compose logs app
|
||||||
|
docker compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker가 설치되지 않음
|
||||||
|
```bash
|
||||||
|
# Docker 자동 설치
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
newgrp docker # 또는 로그아웃 후 재로그인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 포트가 이미 사용 중
|
||||||
|
```bash
|
||||||
|
# docker-compose.yml 파일 편집
|
||||||
|
nano docker-compose.yml
|
||||||
|
|
||||||
|
# ports 섹션 수정
|
||||||
|
# "8001:8000" # 8000 대신 8001 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗑️ 완전 삭제
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 컨테이너 + 볼륨 모두 삭제
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
# 이미지도 삭제
|
||||||
|
docker rmi asset_pilot_docker-app postgres:16-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 모바일/태블릿 접속
|
||||||
|
|
||||||
|
1. 같은 Wi-Fi에 연결
|
||||||
|
2. 브라우저에서 `http://[IP]:8000` 접속
|
||||||
|
3. **홈 화면에 추가** → 앱처럼 사용!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 보안 팁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 파일 권한 설정
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
# 방화벽 설정
|
||||||
|
sudo ufw allow 8000/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
|
||||||
|
# PostgreSQL 외부 접근 차단 (기본값 유지)
|
||||||
|
# docker-compose.yml에서 5432 포트를 127.0.0.1:5432:5432로 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 설치 확인 체크리스트
|
||||||
|
|
||||||
|
- [ ] Docker 설치됨 (`docker --version`)
|
||||||
|
- [ ] 프로젝트 압축 해제됨
|
||||||
|
- [ ] `.env` 파일 비밀번호 설정됨
|
||||||
|
- [ ] `docker compose up -d` 실행됨
|
||||||
|
- [ ] 컨테이너 실행 중 (`docker compose ps`)
|
||||||
|
- [ ] DB 초기화 완료
|
||||||
|
- [ ] 웹 접속 가능 (`http://[IP]:8000`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 장점 요약
|
||||||
|
|
||||||
|
| 기능 | 일반 설치 | Docker 설치 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| 설치 복잡도 | 중간 | 매우 쉬움 |
|
||||||
|
| 환경 격리 | 없음 | 완벽 |
|
||||||
|
| 백업/복원 | 수동 | 간단 |
|
||||||
|
| 업데이트 | 복잡 | 쉬움 |
|
||||||
|
| 이식성 | 낮음 | 높음 |
|
||||||
|
| 롤백 | 어려움 | 쉬움 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**모든 준비 완료! 투자 모니터링을 시작하세요! 💰**
|
||||||
|
|
||||||
|
상세한 내용은 `DOCKER_GUIDE.md` 참조
|
||||||
368
.TemporaryDocument/MIGRATION_GUIDE.md
Normal file
368
.TemporaryDocument/MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
# Asset Pilot - Windows → Orange Pi 5 Plus 전환 가이드
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
**목적**: Windows PyQt5 데스크톱 애플리케이션을 Orange Pi 5 Plus (Ubuntu 24.04) 기반 웹 애플리케이션으로 전환
|
||||||
|
|
||||||
|
**주요 변경사항**:
|
||||||
|
- PyQt5 GUI → FastAPI + HTML/JavaScript 웹 인터페이스
|
||||||
|
- CSV 파일 저장 → PostgreSQL 데이터베이스
|
||||||
|
- Windows 토스트 알림 → 웹 푸시 알림 (선택적)
|
||||||
|
- 로컬 실행 → 네트워크 기반 접근 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 시스템 요구사항
|
||||||
|
|
||||||
|
### 하드웨어
|
||||||
|
- Orange Pi 5 Plus (ARM64 아키텍처)
|
||||||
|
- 최소 2GB RAM (4GB 권장)
|
||||||
|
- 최소 8GB 저장공간
|
||||||
|
|
||||||
|
### 소프트웨어
|
||||||
|
- Ubuntu 24.04 LTS (Joshua Riek 버전)
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- Python 3.10+
|
||||||
|
- Nginx (선택적, 프로덕션 배포용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 설치 단계
|
||||||
|
|
||||||
|
### 1단계: 시스템 패키지 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 시스템 업데이트
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# PostgreSQL 설치
|
||||||
|
sudo apt install -y postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Python 및 개발 도구 설치
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv python3-dev
|
||||||
|
sudo apt install -y build-essential libpq-dev
|
||||||
|
|
||||||
|
# Nginx 설치 (선택적)
|
||||||
|
sudo apt install -y nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: PostgreSQL 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL 서비스 시작
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
|
||||||
|
# 데이터베이스 및 사용자 생성
|
||||||
|
sudo -u postgres psql << EOF
|
||||||
|
CREATE DATABASE asset_pilot;
|
||||||
|
CREATE USER asset_user WITH ENCRYPTED PASSWORD 'your_secure_password';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE asset_pilot TO asset_user;
|
||||||
|
\q
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 애플리케이션 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 작업 디렉토리 생성
|
||||||
|
mkdir -p ~/asset_pilot_web
|
||||||
|
cd ~/asset_pilot_web
|
||||||
|
|
||||||
|
# 제공된 파일 압축 해제
|
||||||
|
unzip asset_pilot_orangepi.zip
|
||||||
|
cd asset_pilot_orangepi
|
||||||
|
|
||||||
|
# Python 가상환경 생성
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# 의존성 패키지 설치
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4단계: 환경 변수 설정
|
||||||
|
|
||||||
|
`.env` 파일 생성:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
# 데이터베이스 설정
|
||||||
|
DATABASE_URL=postgresql://asset_user:your_secure_password@localhost/asset_pilot
|
||||||
|
|
||||||
|
# 서버 설정
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
DEBUG=False
|
||||||
|
|
||||||
|
# 데이터 수집 간격 (초)
|
||||||
|
FETCH_INTERVAL=5
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5단계: 데이터베이스 초기화
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 가상환경 활성화 상태에서
|
||||||
|
python init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6단계: 서비스 등록 (자동 실행)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# systemd 서비스 파일 복사
|
||||||
|
sudo cp asset_pilot.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# 서비스 파일 편집 (경로 확인)
|
||||||
|
sudo nano /etc/systemd/system/asset_pilot.service
|
||||||
|
|
||||||
|
# 서비스 활성화
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable asset_pilot.service
|
||||||
|
sudo systemctl start asset_pilot.service
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
sudo systemctl status asset_pilot.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 웹 인터페이스 접근
|
||||||
|
|
||||||
|
### 로컬 네트워크에서 접근
|
||||||
|
|
||||||
|
1. Orange Pi의 IP 주소 확인:
|
||||||
|
```bash
|
||||||
|
ip addr show | grep inet
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 웹 브라우저에서 접속:
|
||||||
|
```
|
||||||
|
http://[Orange_Pi_IP]:8000
|
||||||
|
```
|
||||||
|
예: `http://192.168.1.100:8000`
|
||||||
|
|
||||||
|
### Nginx 리버스 프록시 설정 (선택적)
|
||||||
|
|
||||||
|
프로덕션 환경에서는 Nginx를 통한 접근 권장:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
다음 내용 입력:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your_domain_or_ip;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1: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://127.0.0.1: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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터 마이그레이션
|
||||||
|
|
||||||
|
### Windows 앱에서 데이터 내보내기
|
||||||
|
|
||||||
|
기존 Windows 앱의 `user_assets.csv` 파일을 Orange Pi로 복사:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows에서 SCP로 전송 (예시)
|
||||||
|
scp user_assets.csv orangepi@192.168.1.100:~/asset_pilot_web/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 가져오기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/asset_pilot_web/asset_pilot_orangepi
|
||||||
|
source venv/bin/activate
|
||||||
|
python import_csv.py ../user_assets.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 모니터링 및 관리
|
||||||
|
|
||||||
|
### 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 실시간 로그 보기
|
||||||
|
sudo journalctl -u asset_pilot.service -f
|
||||||
|
|
||||||
|
# 최근 100줄 로그
|
||||||
|
sudo journalctl -u asset_pilot.service -n 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 시작
|
||||||
|
sudo systemctl start asset_pilot.service
|
||||||
|
|
||||||
|
# 중지
|
||||||
|
sudo systemctl stop asset_pilot.service
|
||||||
|
|
||||||
|
# 재시작
|
||||||
|
sudo systemctl restart asset_pilot.service
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
sudo systemctl status asset_pilot.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터베이스 백업
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 백업 생성
|
||||||
|
pg_dump -U asset_user -h localhost asset_pilot > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# 복원
|
||||||
|
psql -U asset_user -h localhost asset_pilot < backup_20260210.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 보안 권장사항
|
||||||
|
|
||||||
|
1. **방화벽 설정**:
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8000/tcp # 또는 Nginx 사용 시 80/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **PostgreSQL 외부 접근 차단**: 기본 설정 유지 (localhost만 허용)
|
||||||
|
|
||||||
|
3. **강력한 비밀번호 사용**: `.env` 파일의 데이터베이스 비밀번호 변경
|
||||||
|
|
||||||
|
4. **HTTPS 설정** (선택적):
|
||||||
|
- Let's Encrypt 인증서 사용
|
||||||
|
- Nginx SSL 설정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 문제 해결
|
||||||
|
|
||||||
|
### 서비스가 시작되지 않음
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로그 확인
|
||||||
|
sudo journalctl -u asset_pilot.service -n 50
|
||||||
|
|
||||||
|
# 수동 실행으로 오류 확인
|
||||||
|
cd ~/asset_pilot_web/asset_pilot_orangepi
|
||||||
|
source venv/bin/activate
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터베이스 연결 오류
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL 상태 확인
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# 연결 테스트
|
||||||
|
psql -U asset_user -h localhost -d asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 포트 충돌
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 8000번 포트 사용 중인 프로세스 확인
|
||||||
|
sudo lsof -i :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 모바일 접근
|
||||||
|
|
||||||
|
### 홈 화면에 추가 (PWA 스타일)
|
||||||
|
|
||||||
|
1. 모바일 브라우저에서 접속
|
||||||
|
2. 브라우저 메뉴 → "홈 화면에 추가"
|
||||||
|
3. 앱처럼 사용 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 업데이트 절차
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/asset_pilot_web/asset_pilot_orangepi
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# 최신 코드 적용
|
||||||
|
git pull # 또는 새 파일 복사
|
||||||
|
|
||||||
|
# 의존성 업데이트
|
||||||
|
pip install -r requirements.txt --upgrade
|
||||||
|
|
||||||
|
# 데이터베이스 마이그레이션 (필요시)
|
||||||
|
python migrate_db.py
|
||||||
|
|
||||||
|
# 서비스 재시작
|
||||||
|
sudo systemctl restart asset_pilot.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 지원
|
||||||
|
|
||||||
|
문제 발생 시:
|
||||||
|
1. 로그 확인: `sudo journalctl -u asset_pilot.service -n 100`
|
||||||
|
2. 데이터베이스 상태 확인: `sudo systemctl status postgresql`
|
||||||
|
3. 네트워크 연결 확인: `ping 8.8.8.8`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 주요 차이점 요약
|
||||||
|
|
||||||
|
| 기능 | Windows 앱 | Orange Pi 웹앱 |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| UI | PyQt5 | HTML/JavaScript |
|
||||||
|
| 저장소 | CSV 파일 | PostgreSQL |
|
||||||
|
| 접근성 | 로컬 실행 | 네트워크 접근 |
|
||||||
|
| 알림 | Windows 토스트 | 브라우저 알림 |
|
||||||
|
| 멀티 기기 | 불가 | 가능 |
|
||||||
|
| 자동 시작 | Windows 작업 스케줄러 | systemd |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
설치 완료 확인:
|
||||||
|
- [ ] PostgreSQL 설치 및 실행
|
||||||
|
- [ ] 데이터베이스 생성
|
||||||
|
- [ ] Python 가상환경 생성
|
||||||
|
- [ ] 의존성 패키지 설치
|
||||||
|
- [ ] 환경 변수 설정
|
||||||
|
- [ ] 데이터베이스 초기화
|
||||||
|
- [ ] 서비스 등록 및 시작
|
||||||
|
- [ ] 웹 브라우저 접속 확인
|
||||||
|
- [ ] 데이터 수집 동작 확인
|
||||||
|
- [ ] 기존 데이터 마이그레이션 (선택적)
|
||||||
|
|
||||||
|
모든 항목 완료 시 시스템 사용 준비 완료! 🎉
|
||||||
126
.TemporaryDocument/QUICKSTART.md
Normal file
126
.TemporaryDocument/QUICKSTART.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Asset Pilot - Orange Pi 빠른 시작 가이드
|
||||||
|
|
||||||
|
## 🚀 5분 안에 시작하기
|
||||||
|
|
||||||
|
### 1️⃣ 파일 전송
|
||||||
|
|
||||||
|
Orange Pi에 `asset_pilot_orangepi.tar.gz` 파일을 전송:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows에서 (PowerShell)
|
||||||
|
scp asset_pilot_orangepi.tar.gz orangepi@192.168.1.100:~/
|
||||||
|
|
||||||
|
# Linux/Mac에서
|
||||||
|
scp asset_pilot_orangepi.tar.gz orangepi@192.168.1.100:~/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ Orange Pi에서 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 접속
|
||||||
|
ssh orangepi@192.168.1.100
|
||||||
|
|
||||||
|
# 압축 해제
|
||||||
|
tar -xzf asset_pilot_orangepi.tar.gz
|
||||||
|
cd asset_pilot_orangepi
|
||||||
|
|
||||||
|
# 자동 설치 스크립트 실행
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
스크립트가 다음을 자동으로 처리합니다:
|
||||||
|
- PostgreSQL 설치 및 설정
|
||||||
|
- Python 가상환경 생성
|
||||||
|
- 필요한 패키지 설치
|
||||||
|
- 데이터베이스 초기화
|
||||||
|
- 서비스 등록
|
||||||
|
|
||||||
|
### 3️⃣ 접속
|
||||||
|
|
||||||
|
웹 브라우저에서:
|
||||||
|
```
|
||||||
|
http://[Orange_Pi_IP]:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
예: `http://192.168.1.100:8000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 스마트폰에서 접속
|
||||||
|
|
||||||
|
1. 같은 Wi-Fi 네트워크에 연결
|
||||||
|
2. 브라우저에서 `http://[IP]:8000` 접속
|
||||||
|
3. "홈 화면에 추가" → 앱처럼 사용!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 서비스 상태 확인
|
||||||
|
sudo systemctl status asset_pilot
|
||||||
|
|
||||||
|
# 로그 실시간 보기
|
||||||
|
sudo journalctl -u asset_pilot -f
|
||||||
|
|
||||||
|
# 서비스 재시작
|
||||||
|
sudo systemctl restart asset_pilot
|
||||||
|
|
||||||
|
# 서비스 중지
|
||||||
|
sudo systemctl stop asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Windows 데이터 가져오기
|
||||||
|
|
||||||
|
기존 `user_assets.csv` 파일이 있다면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CSV 파일을 Orange Pi로 복사
|
||||||
|
scp user_assets.csv orangepi@192.168.1.100:~/
|
||||||
|
|
||||||
|
# Orange Pi에서 가져오기
|
||||||
|
cd ~/asset_pilot_orangepi
|
||||||
|
source venv/bin/activate
|
||||||
|
python import_csv.py ../user_assets.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 문제 해결
|
||||||
|
|
||||||
|
### 접속이 안 될 때
|
||||||
|
|
||||||
|
1. 서비스 확인:
|
||||||
|
```bash
|
||||||
|
sudo systemctl status asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 방화벽 확인:
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8000/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. IP 주소 확인:
|
||||||
|
```bash
|
||||||
|
ip addr show
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터가 수집되지 않을 때
|
||||||
|
|
||||||
|
로그 확인:
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u asset_pilot -n 100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 더 자세한 정보
|
||||||
|
|
||||||
|
- **전체 가이드**: MIGRATION_GUIDE.md
|
||||||
|
- **프로젝트 문서**: README.md (프로젝트 내)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**준비 완료! 이제 자산을 모니터링하세요! 💰**
|
||||||
BIN
.TemporaryDocument/asset_pilot_docker.tar.gz
Normal file
BIN
.TemporaryDocument/asset_pilot_docker.tar.gz
Normal file
Binary file not shown.
BIN
.TemporaryDocument/asset_pilot_orangepi.tar.gz
Normal file
BIN
.TemporaryDocument/asset_pilot_orangepi.tar.gz
Normal file
Binary file not shown.
BIN
.TemporaryDocument/assetpilot.ico
Normal file
BIN
.TemporaryDocument/assetpilot.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
130
.TemporaryDocument/claude.md
Normal file
130
.TemporaryDocument/claude.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
이걸 postgresql 도커 따로 개발프로그램 실행 도커 따로 이렇게 설치하고 싶은데 과정알려줘
|
||||||
|
|
||||||
|
오전 8:10
|
||||||
|
|
||||||
|
4개의 파일 생성됨, 명령 4개 실행함
|
||||||
|
이제 다시 파일들을 생성하겠습니다.
|
||||||
|
|
||||||
|
|
||||||
|
명령 3개 실행함
|
||||||
|
좋습니다! 이제 Docker 관련 파일들을 추가하겠습니다.
|
||||||
|
|
||||||
|
|
||||||
|
9개의 파일 생성됨, 명령 실행함
|
||||||
|
이제 프로젝트를 압축하여 제공하겠습니다.
|
||||||
|
|
||||||
|
|
||||||
|
명령 2개 실행함, 파일 생성됨
|
||||||
|
완벽합니다! 이제 모든 파일을 제공하겠습니다.
|
||||||
|
|
||||||
|
|
||||||
|
Presented 2 files
|
||||||
|
완료했습니다! 🐳
|
||||||
|
|
||||||
|
📦 Docker 버전 제공 완료
|
||||||
|
제공된 파일
|
||||||
|
asset_pilot_docker.tar.gz - Docker 기반 전체 패키지
|
||||||
|
DOCKER_QUICKSTART.md - Docker 빠른 시작 가이드
|
||||||
|
🐳 Docker 설치 방식의 장점
|
||||||
|
독립된 컨테이너 구조
|
||||||
|
PostgreSQL Container (DB) ←→ App Container (FastAPI)
|
||||||
|
↓ ↓
|
||||||
|
Volume (postgres_data) Volume (app_logs)
|
||||||
|
주요 이점:
|
||||||
|
|
||||||
|
✅ PostgreSQL과 앱이 별도 컨테이너로 완전 격리
|
||||||
|
✅ 한 번의 명령으로 전체 시스템 실행
|
||||||
|
✅ 컨테이너별 독립적 재시작 가능
|
||||||
|
✅ 볼륨으로 데이터 영구 저장
|
||||||
|
✅ 백업/복원 매우 간단
|
||||||
|
✅ 이식성 최고 (다른 서버로 쉽게 이동)
|
||||||
|
🚀 설치 과정
|
||||||
|
초간단 설치 (3단계)
|
||||||
|
bash
|
||||||
|
# 1. 압축 해제
|
||||||
|
tar -xzf asset_pilot_docker.tar.gz
|
||||||
|
cd asset_pilot_docker
|
||||||
|
|
||||||
|
# 2. 자동 설치 실행
|
||||||
|
bash start.sh
|
||||||
|
|
||||||
|
# 3. 완료! 접속
|
||||||
|
# http://[IP]:8000
|
||||||
|
start.sh 스크립트가 자동으로:
|
||||||
|
Docker 설치 확인 (없으면 자동 설치)
|
||||||
|
Docker Compose 확인 (없으면 자동 설치)
|
||||||
|
비밀번호 입력 받아 .env 파일 생성
|
||||||
|
PostgreSQL 컨테이너 시작
|
||||||
|
App 컨테이너 빌드 및 시작
|
||||||
|
데이터베이스 자동 초기화
|
||||||
|
접속 URL 안내
|
||||||
|
🔧 주요 관리 명령어
|
||||||
|
컨테이너 관리
|
||||||
|
bash
|
||||||
|
docker compose up -d # 시작 (백그라운드)
|
||||||
|
docker compose down # 중지
|
||||||
|
docker compose ps # 상태 확인
|
||||||
|
docker compose logs -f # 로그 실시간
|
||||||
|
docker compose restart app # 앱만 재시작
|
||||||
|
데이터 관리
|
||||||
|
bash
|
||||||
|
# 백업
|
||||||
|
docker compose exec postgres pg_dump -U asset_user asset_pilot > backup.sql
|
||||||
|
|
||||||
|
# 복원
|
||||||
|
cat backup.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot
|
||||||
|
|
||||||
|
# CSV 가져오기
|
||||||
|
docker cp user_assets.csv asset_pilot_app:/app/
|
||||||
|
docker compose exec app python import_csv.py user_assets.csv
|
||||||
|
📊 컨테이너 구성 상세
|
||||||
|
PostgreSQL 컨테이너
|
||||||
|
이미지: postgres:16-alpine (경량)
|
||||||
|
포트: 5432 (내부 네트워크만)
|
||||||
|
볼륨: postgres_data (데이터 영구 저장)
|
||||||
|
헬스체크: 자동 상태 확인
|
||||||
|
App 컨테이너
|
||||||
|
이미지: 자체 빌드 (Python 3.11)
|
||||||
|
포트: 8000 (외부 접근 가능)
|
||||||
|
의존성: PostgreSQL 준비 완료 후 시작
|
||||||
|
헬스체크: /health 엔드포인트 확인
|
||||||
|
🔄 업데이트 방법
|
||||||
|
bash
|
||||||
|
# 코드 업데이트 후
|
||||||
|
docker compose build app # 이미지 재빌드
|
||||||
|
docker compose up -d app # 앱만 재시작
|
||||||
|
🔐 보안 설정
|
||||||
|
PostgreSQL: 내부 네트워크만 접근
|
||||||
|
.env 파일: 권한 600 (소유자만 읽기/쓰기)
|
||||||
|
방화벽: 8000번 포트만 개방
|
||||||
|
비루트 사용자: 컨테이너 내부에서 appuser로 실행
|
||||||
|
💡 일반 설치 vs Docker 설치
|
||||||
|
항목 일반 설치 Docker 설치
|
||||||
|
설치 난이도 중간 (여러 단계) 쉬움 (3단계)
|
||||||
|
의존성 관리 수동 설치 필요 자동 처리
|
||||||
|
환경 격리 없음 완벽
|
||||||
|
백업 수동 (복잡) 간단
|
||||||
|
업데이트 복잡 쉬움
|
||||||
|
롤백 어려움 쉬움
|
||||||
|
이식성 낮음 높음
|
||||||
|
리소스 가벼움 약간 더 사용
|
||||||
|
📚 포함된 문서
|
||||||
|
프로젝트 내부 (압축 해제 후):
|
||||||
|
|
||||||
|
README.md - 프로젝트 개요
|
||||||
|
DOCKER_GUIDE.md - 상세 설치/관리 가이드
|
||||||
|
docker-compose.yml - 컨테이너 설정
|
||||||
|
Dockerfile - 앱 이미지 빌드 설정
|
||||||
|
start.sh - 자동 설치 스크립트
|
||||||
|
🎯 권장 사용 시나리오
|
||||||
|
Docker 설치 추천:
|
||||||
|
|
||||||
|
✅ 깔끔한 환경 원할 때
|
||||||
|
✅ 쉬운 백업/복원 필요할 때
|
||||||
|
✅ 여러 서버에 배포할 때
|
||||||
|
✅ 컨테이너 기술 선호할 때
|
||||||
|
일반 설치 추천:
|
||||||
|
|
||||||
|
✅ Docker 없는 환경
|
||||||
|
✅ 최소 리소스 사용
|
||||||
|
✅ 직접 제어 선호
|
||||||
21
.TemporaryDocument/필요 Linux명령들 모음.md
Normal file
21
.TemporaryDocument/필요 Linux명령들 모음.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 프로그램 수정후
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 실행을 터미널 에서 보고 싶으면
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
|
||||||
|
# AssetPilot의 오렌지 파이 리소스 부하 확인하고 싶으면
|
||||||
|
docker stats asset_pilot_app asset_pilot_db
|
||||||
|
|
||||||
|
# docker compose down 해야 하는 경우
|
||||||
|
보통의 코드 수정(Python 파일 수정 등)에는 up --build만으로 충분하지만, 아래 상황에서는 down을 먼저 하는 것이 깔끔합니다.
|
||||||
|
|
||||||
|
포트 변경: docker-compose.yml에서 ports 설정을 바꿨을 때.
|
||||||
|
|
||||||
|
네트워크 구조 변경: 서비스 간의 네트워크 연결 방식을 크게 수정했을 때.
|
||||||
|
|
||||||
|
완전 초기화: volumes에 쌓인 데이터까지 싹 지우고 새로 시작하고 싶을 때 (이때는 docker compose down -v).
|
||||||
|
|
||||||
|
좀비 컨테이너: 가끔 도커 엔진의 버그로 컨테이너가 정상적으로 교체되지 않고 꼬여있을 때.
|
||||||
|
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
postgres_data/ # DB 데이터는 따로 관리
|
||||||
|
.env # 비밀번호 등 민감 정보
|
||||||
59
asset_pilot_docker/.gitignore
vendored
Normal file
59
asset_pilot_docker/.gitignore
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# 환경 변수
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# 데이터베이스
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# 로그
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# CSV 데이터
|
||||||
|
*.csv
|
||||||
|
!example.csv
|
||||||
|
|
||||||
|
# 백업 파일
|
||||||
|
*.sql
|
||||||
|
backup/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
247
asset_pilot_docker/.venv/bin/Activate.ps1
Normal file
247
asset_pilot_docker/.venv/bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
70
asset_pilot_docker/.venv/bin/activate
Normal file
70
asset_pilot_docker/.venv/bin/activate
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# You cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Call hash to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
unset VIRTUAL_ENV_PROMPT
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||||
|
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||||
|
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||||
|
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||||
|
export VIRTUAL_ENV=$(cygpath /home/ubuntu/AssetPilot/asset_pilot_docker/.venv)
|
||||||
|
else
|
||||||
|
# use the path as-is
|
||||||
|
export VIRTUAL_ENV=/home/ubuntu/AssetPilot/asset_pilot_docker/.venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1='(.venv) '"${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
VIRTUAL_ENV_PROMPT='(.venv) '
|
||||||
|
export VIRTUAL_ENV_PROMPT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Call hash to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
hash -r 2> /dev/null
|
||||||
27
asset_pilot_docker/.venv/bin/activate.csh
Normal file
27
asset_pilot_docker/.venv/bin/activate.csh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||||
|
# You cannot run it directly.
|
||||||
|
|
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
setenv VIRTUAL_ENV /home/ubuntu/AssetPilot/asset_pilot_docker/.venv
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||||
|
set prompt = '(.venv) '"$prompt"
|
||||||
|
setenv VIRTUAL_ENV_PROMPT '(.venv) '
|
||||||
|
endif
|
||||||
|
|
||||||
|
alias pydoc python -m pydoc
|
||||||
|
|
||||||
|
rehash
|
||||||
69
asset_pilot_docker/.venv/bin/activate.fish
Normal file
69
asset_pilot_docker/.venv/bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||||
|
# (https://fishshell.com/). You cannot run it directly.
|
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||||
|
# reset old environment variables
|
||||||
|
if test -n "$_OLD_VIRTUAL_PATH"
|
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||||
|
set -e _OLD_VIRTUAL_PATH
|
||||||
|
end
|
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||||
|
# prevents error when using nested fish instances (Issue #93858)
|
||||||
|
if functions -q _old_fish_prompt
|
||||||
|
functions -e fish_prompt
|
||||||
|
functions -c _old_fish_prompt fish_prompt
|
||||||
|
functions -e _old_fish_prompt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
set -e VIRTUAL_ENV
|
||||||
|
set -e VIRTUAL_ENV_PROMPT
|
||||||
|
if test "$argv[1]" != "nondestructive"
|
||||||
|
# Self-destruct!
|
||||||
|
functions -e deactivate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV /home/ubuntu/AssetPilot/asset_pilot_docker/.venv
|
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
|
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||||
|
|
||||||
|
# Unset PYTHONHOME if set.
|
||||||
|
if set -q PYTHONHOME
|
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||||
|
set -e PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||||
|
# fish uses a function instead of an env var to generate the prompt.
|
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||||
|
functions -c fish_prompt _old_fish_prompt
|
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own.
|
||||||
|
function fish_prompt
|
||||||
|
# Save the return status of the last command.
|
||||||
|
set -l old_status $status
|
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||||
|
printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal)
|
||||||
|
|
||||||
|
# Restore the return status of the previous command.
|
||||||
|
echo "exit $old_status" | .
|
||||||
|
# Output the original/"old" prompt.
|
||||||
|
_old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||||
|
set -gx VIRTUAL_ENV_PROMPT '(.venv) '
|
||||||
|
end
|
||||||
8
asset_pilot_docker/.venv/bin/dotenv
Executable file
8
asset_pilot_docker/.venv/bin/dotenv
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dotenv.__main__ import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
8
asset_pilot_docker/.venv/bin/normalizer
Executable file
8
asset_pilot_docker/.venv/bin/normalizer
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from charset_normalizer.cli import cli_detect
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli_detect())
|
||||||
8
asset_pilot_docker/.venv/bin/pip
Executable file
8
asset_pilot_docker/.venv/bin/pip
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
asset_pilot_docker/.venv/bin/pip3
Executable file
8
asset_pilot_docker/.venv/bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
asset_pilot_docker/.venv/bin/pip3.12
Executable file
8
asset_pilot_docker/.venv/bin/pip3.12
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
1
asset_pilot_docker/.venv/bin/python
Symbolic link
1
asset_pilot_docker/.venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
1
asset_pilot_docker/.venv/bin/python3
Symbolic link
1
asset_pilot_docker/.venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
1
asset_pilot_docker/.venv/bin/python3.12
Symbolic link
1
asset_pilot_docker/.venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
8
asset_pilot_docker/.venv/bin/uvicorn
Executable file
8
asset_pilot_docker/.venv/bin/uvicorn
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from uvicorn.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
asset_pilot_docker/.venv/bin/watchfiles
Executable file
8
asset_pilot_docker/.venv/bin/watchfiles
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from watchfiles.cli import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
8
asset_pilot_docker/.venv/bin/websockets
Executable file
8
asset_pilot_docker/.venv/bin/websockets
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/home/ubuntu/AssetPilot/asset_pilot_docker/.venv/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from websockets.cli import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||||
|
|
||||||
|
/* Greenlet object interface */
|
||||||
|
|
||||||
|
#ifndef Py_GREENLETOBJECT_H
|
||||||
|
#define Py_GREENLETOBJECT_H
|
||||||
|
|
||||||
|
|
||||||
|
#include <Python.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* This is deprecated and undocumented. It does not change. */
|
||||||
|
#define GREENLET_VERSION "1.0.0"
|
||||||
|
|
||||||
|
#ifndef GREENLET_MODULE
|
||||||
|
#define implementation_ptr_t void*
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct _greenlet {
|
||||||
|
PyObject_HEAD
|
||||||
|
PyObject* weakreflist;
|
||||||
|
PyObject* dict;
|
||||||
|
implementation_ptr_t pimpl;
|
||||||
|
} PyGreenlet;
|
||||||
|
|
||||||
|
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||||
|
|
||||||
|
|
||||||
|
/* C API functions */
|
||||||
|
|
||||||
|
/* Total number of symbols that are exported */
|
||||||
|
#define PyGreenlet_API_pointers 12
|
||||||
|
|
||||||
|
#define PyGreenlet_Type_NUM 0
|
||||||
|
#define PyExc_GreenletError_NUM 1
|
||||||
|
#define PyExc_GreenletExit_NUM 2
|
||||||
|
|
||||||
|
#define PyGreenlet_New_NUM 3
|
||||||
|
#define PyGreenlet_GetCurrent_NUM 4
|
||||||
|
#define PyGreenlet_Throw_NUM 5
|
||||||
|
#define PyGreenlet_Switch_NUM 6
|
||||||
|
#define PyGreenlet_SetParent_NUM 7
|
||||||
|
|
||||||
|
#define PyGreenlet_MAIN_NUM 8
|
||||||
|
#define PyGreenlet_STARTED_NUM 9
|
||||||
|
#define PyGreenlet_ACTIVE_NUM 10
|
||||||
|
#define PyGreenlet_GET_PARENT_NUM 11
|
||||||
|
|
||||||
|
#ifndef GREENLET_MODULE
|
||||||
|
/* This section is used by modules that uses the greenlet C API */
|
||||||
|
static void** _PyGreenlet_API = NULL;
|
||||||
|
|
||||||
|
# define PyGreenlet_Type \
|
||||||
|
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||||
|
|
||||||
|
# define PyExc_GreenletError \
|
||||||
|
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||||
|
|
||||||
|
# define PyExc_GreenletExit \
|
||||||
|
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_New(PyObject *args)
|
||||||
|
*
|
||||||
|
* greenlet.greenlet(run, parent=None)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_New \
|
||||||
|
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_GetCurrent(void)
|
||||||
|
*
|
||||||
|
* greenlet.getcurrent()
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GetCurrent \
|
||||||
|
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_Throw(
|
||||||
|
* PyGreenlet *greenlet,
|
||||||
|
* PyObject *typ,
|
||||||
|
* PyObject *val,
|
||||||
|
* PyObject *tb)
|
||||||
|
*
|
||||||
|
* g.throw(...)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_Throw \
|
||||||
|
(*(PyObject * (*)(PyGreenlet * self, \
|
||||||
|
PyObject * typ, \
|
||||||
|
PyObject * val, \
|
||||||
|
PyObject * tb)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||||
|
*
|
||||||
|
* g.switch(*args, **kwargs)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_Switch \
|
||||||
|
(*(PyObject * \
|
||||||
|
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||||
|
*
|
||||||
|
* g.parent = new_parent
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_SetParent \
|
||||||
|
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||||
|
*
|
||||||
|
* return greenlet.parent;
|
||||||
|
*
|
||||||
|
* This could return NULL even if there is no exception active.
|
||||||
|
* If it does not return NULL, you are responsible for decrementing the
|
||||||
|
* reference count.
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GetParent \
|
||||||
|
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* deprecated, undocumented alias.
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||||
|
|
||||||
|
# define PyGreenlet_MAIN \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||||
|
|
||||||
|
# define PyGreenlet_STARTED \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||||
|
|
||||||
|
# define PyGreenlet_ACTIVE \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Macro that imports greenlet and initializes C API */
|
||||||
|
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||||
|
keep the older definition to be sure older code that might have a copy of
|
||||||
|
the header still works. */
|
||||||
|
# define PyGreenlet_Import() \
|
||||||
|
{ \
|
||||||
|
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* GREENLET_MODULE */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif /* !Py_GREENLETOBJECT_H */
|
||||||
1
asset_pilot_docker/.venv/lib64
Symbolic link
1
asset_pilot_docker/.venv/lib64
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
5
asset_pilot_docker/.venv/pyvenv.cfg
Normal file
5
asset_pilot_docker/.venv/pyvenv.cfg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.3
|
||||||
|
executable = /usr/bin/python3.12
|
||||||
|
command = /usr/bin/python3 -m venv /home/ubuntu/AssetPilot/asset_pilot_docker/.venv
|
||||||
420
asset_pilot_docker/DOCKER_GUIDE.md
Normal file
420
asset_pilot_docker/DOCKER_GUIDE.md
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
34
asset_pilot_docker/Dockerfile
Normal file
34
asset_pilot_docker/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 작업 디렉토리 설정
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 시스템 패키지 업데이트 및 필수 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python 의존성 파일 복사 및 설치
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 애플리케이션 파일 복사 (구조를 유지하며 복사)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 로그 디렉토리 생성
|
||||||
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
|
# 비루트 사용자 생성 및 권한 설정
|
||||||
|
RUN useradd -m -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# 헬스체크 엔드포인트 노출
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 컨테이너 시작 시 실행할 명령어 (uvicorn으로 직접 실행 권장)
|
||||||
|
CMD ["python", "main.py"]
|
||||||
159
asset_pilot_docker/README.md
Normal file
159
asset_pilot_docker/README.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Asset Pilot - Docker Edition
|
||||||
|
|
||||||
|
🐳 Docker 컨테이너 기반 자산 모니터링 시스템
|
||||||
|
|
||||||
|
## 📦 구성
|
||||||
|
|
||||||
|
이 프로젝트는 2개의 독립된 Docker 컨테이너로 구성됩니다:
|
||||||
|
|
||||||
|
1. **PostgreSQL 컨테이너** (`asset_pilot_db`)
|
||||||
|
- 데이터베이스 서버
|
||||||
|
- 포트: 5432
|
||||||
|
- 볼륨: `postgres_data`
|
||||||
|
|
||||||
|
2. **Asset Pilot 앱 컨테이너** (`asset_pilot_app`)
|
||||||
|
- FastAPI 웹 애플리케이션
|
||||||
|
- 포트: 8000
|
||||||
|
- 실시간 데이터 수집 및 제공
|
||||||
|
|
||||||
|
## 🚀 빠른 시작
|
||||||
|
|
||||||
|
### 자동 설치 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 압축 해제
|
||||||
|
tar -xzf asset_pilot_docker.tar.gz
|
||||||
|
cd asset_pilot_docker
|
||||||
|
|
||||||
|
# 자동 설치 스크립트 실행
|
||||||
|
bash start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수동 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 환경 변수 설정
|
||||||
|
nano .env
|
||||||
|
# DB_PASSWORD를 원하는 비밀번호로 변경
|
||||||
|
|
||||||
|
# 2. Docker 컨테이너 시작
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. 데이터베이스 초기화
|
||||||
|
docker compose exec app python init_db.py
|
||||||
|
|
||||||
|
# 4. 브라우저에서 접속
|
||||||
|
# http://[IP주소]:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
asset_pilot_docker/
|
||||||
|
├── app/ # 애플리케이션 코드
|
||||||
|
│ ├── calculator.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── fetcher.py
|
||||||
|
│ └── models.py
|
||||||
|
├── static/ # 정적 파일
|
||||||
|
│ ├── css/
|
||||||
|
│ └── js/
|
||||||
|
├── templates/ # HTML 템플릿
|
||||||
|
│ └── index.html
|
||||||
|
├── docker-compose.yml # Docker Compose 설정
|
||||||
|
├── Dockerfile # 앱 컨테이너 이미지
|
||||||
|
├── .env # 환경 변수
|
||||||
|
├── main.py # FastAPI 메인
|
||||||
|
├── init_db.py # DB 초기화
|
||||||
|
├── import_csv.py # CSV 가져오기
|
||||||
|
├── start.sh # 자동 설치 스크립트
|
||||||
|
└── DOCKER_GUIDE.md # 상세 가이드
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 주요 명령어
|
||||||
|
|
||||||
|
### 컨테이너 관리
|
||||||
|
```bash
|
||||||
|
# 시작
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 중지
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 재시작
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 로그 보기
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 관리
|
||||||
|
```bash
|
||||||
|
# DB 백업
|
||||||
|
docker compose exec postgres pg_dump -U asset_user asset_pilot > backup.sql
|
||||||
|
|
||||||
|
# DB 복원
|
||||||
|
cat backup.sql | docker compose exec -T postgres psql -U asset_user -d asset_pilot
|
||||||
|
|
||||||
|
# CSV 가져오기
|
||||||
|
docker cp user_assets.csv asset_pilot_app:/app/
|
||||||
|
docker compose exec app python import_csv.py user_assets.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 접속
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8000 # 로컬
|
||||||
|
http://[IP주소]:8000 # 네트워크
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 문서
|
||||||
|
|
||||||
|
- **DOCKER_GUIDE.md** - Docker 상세 설치 및 관리 가이드
|
||||||
|
- 문제 해결, 백업, 업데이트 방법 포함
|
||||||
|
|
||||||
|
## 🔐 보안
|
||||||
|
|
||||||
|
- `.env` 파일에 비밀번호 저장 (권한: 600)
|
||||||
|
- PostgreSQL은 내부 네트워크만 접근 가능
|
||||||
|
- 방화벽에서 필요한 포트만 개방
|
||||||
|
|
||||||
|
## 🆘 문제 해결
|
||||||
|
|
||||||
|
### 컨테이너가 시작되지 않음
|
||||||
|
```bash
|
||||||
|
docker compose logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터베이스 연결 오류
|
||||||
|
```bash
|
||||||
|
docker compose exec postgres pg_isready -U asset_user -d asset_pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 포트 충돌
|
||||||
|
```bash
|
||||||
|
# docker-compose.yml에서 포트 변경
|
||||||
|
# "8001:8000" 으로 수정
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 시스템 요구사항
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- 최소 2GB RAM
|
||||||
|
- 최소 5GB 디스크 공간
|
||||||
|
|
||||||
|
## 🎯 특징
|
||||||
|
|
||||||
|
✅ 독립된 컨테이너로 시스템 격리
|
||||||
|
✅ 한 번의 명령으로 전체 시스템 실행
|
||||||
|
✅ 쉬운 백업 및 복구
|
||||||
|
✅ 업데이트 및 롤백 간편
|
||||||
|
✅ 개발/프로덕션 환경 일관성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Asset Pilot Docker Edition v1.0**
|
||||||
59
asset_pilot_docker/app/calculator.py
Normal file
59
asset_pilot_docker/app/calculator.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
class Calculator:
|
||||||
|
"""손익 계산 클래스"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calc_pnl(
|
||||||
|
gold_buy_price: float,
|
||||||
|
gold_quantity: float,
|
||||||
|
btc_buy_price: float,
|
||||||
|
btc_quantity: float,
|
||||||
|
current_gold: Optional[float],
|
||||||
|
current_btc: Optional[float]
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
금과 BTC의 손익 계산
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"금손익": float,
|
||||||
|
"금손익%": float,
|
||||||
|
"BTC손익": float,
|
||||||
|
"BTC손익%": float,
|
||||||
|
"총손익": float,
|
||||||
|
"총손익%": float
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"금손익": 0.0,
|
||||||
|
"금손익%": 0.0,
|
||||||
|
"BTC손익": 0.0,
|
||||||
|
"BTC손익%": 0.0,
|
||||||
|
"총손익": 0.0,
|
||||||
|
"총손익%": 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 금 손익 계산
|
||||||
|
if current_gold:
|
||||||
|
cost_gold = gold_buy_price * gold_quantity
|
||||||
|
pnl_gold = gold_quantity * (float(current_gold) - gold_buy_price)
|
||||||
|
result["금손익"] = round(pnl_gold, 0)
|
||||||
|
if cost_gold > 0:
|
||||||
|
result["금손익%"] = round((pnl_gold / cost_gold * 100), 2)
|
||||||
|
|
||||||
|
# BTC 손익 계산
|
||||||
|
if current_btc:
|
||||||
|
cost_btc = btc_buy_price * btc_quantity
|
||||||
|
pnl_btc = btc_quantity * (float(current_btc) - btc_buy_price)
|
||||||
|
result["BTC손익"] = round(pnl_btc, 0)
|
||||||
|
if cost_btc > 0:
|
||||||
|
result["BTC손익%"] = round((pnl_btc / cost_btc * 100), 2)
|
||||||
|
|
||||||
|
# 총 손익 계산
|
||||||
|
result["총손익"] = result["금손익"] + result["BTC손익"]
|
||||||
|
total_cost = (gold_buy_price * gold_quantity) + (btc_buy_price * btc_quantity)
|
||||||
|
if total_cost > 0:
|
||||||
|
result["총손익%"] = round((result["총손익"] / total_cost * 100), 2)
|
||||||
|
|
||||||
|
return result
|
||||||
29
asset_pilot_docker/app/database.py
Normal file
29
asset_pilot_docker/app/database.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# 데이터베이스 URL 가져오기
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://asset_user:password@localhost/asset_pilot")
|
||||||
|
|
||||||
|
# SQLAlchemy 엔진 생성
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True, # 연결 유효성 자동 확인
|
||||||
|
echo=False # SQL 쿼리 로그 (디버깅 시 True로 변경)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 세션 팩토리 생성
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""데이터베이스 세션 의존성"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
113
asset_pilot_docker/app/fetcher.py
Normal file
113
asset_pilot_docker/app/fetcher.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from typing import Dict, Optional
|
||||||
|
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 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
|
||||||
|
})
|
||||||
|
|
||||||
|
def fetch_investing_com(self, asset_code: str) -> Optional[float]:
|
||||||
|
"""인베스팅닷컴 (윈도우 앱 방식 정규식 적용)"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
|
||||||
|
if asset_code == "USD/DXY":
|
||||||
|
url = "https://www.investing.com/indices/usdollar"
|
||||||
|
|
||||||
|
# allow_redirects를 True로 하여 주소 변경에 대응
|
||||||
|
response = self.session.get(url, timeout=10, allow_redirects=True)
|
||||||
|
html = response.text
|
||||||
|
|
||||||
|
# 윈도우에서 가장 잘 되던 패턴 순서대로 시도
|
||||||
|
patterns = [
|
||||||
|
r'data-test="instrument-price-last">([\d,.]+)<',
|
||||||
|
r'last_last">([\d,.]+)<',
|
||||||
|
r'instrument-price-last">([\d,.]+)<'
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
p = re.search(pattern, html)
|
||||||
|
if p:
|
||||||
|
return float(p.group(1).replace(',', ''))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Investing 수집 실패 ({asset_code}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_binance(self) -> Optional[float]:
|
||||||
|
"""바이낸스 BTC/USDT (보내주신 윈도우 코드 로직)"""
|
||||||
|
url = "https://api.binance.com/api/v3/ticker/price"
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
return float(response.json()["price"])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Binance API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_upbit(self) -> Optional[float]:
|
||||||
|
"""업비트 BTC/KRW (보내주신 윈도우 코드 로직)"""
|
||||||
|
url = "https://api.upbit.com/v1/ticker"
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params={"markets": "KRW-BTC"}, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return float(data[0]["trade_price"]) if data else None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Upbit API 실패: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_usd_krw(self) -> Optional[float]:
|
||||||
|
"""USD/KRW 환율 (DNS 에러 방지 이중화)"""
|
||||||
|
# 방법 1: 두나무 CDN (원래 주소)
|
||||||
|
try:
|
||||||
|
url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD"
|
||||||
|
res = requests.get(url, timeout=3)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return float(res.json()[0]["basePrice"])
|
||||||
|
except:
|
||||||
|
pass # 실패하면 바로 인베스팅닷컴으로 전환
|
||||||
|
|
||||||
|
# 방법 2: 인베스팅닷컴에서 환율 가져오기 (가장 확실한 백업)
|
||||||
|
return self.fetch_investing_com("USD/KRW")
|
||||||
|
|
||||||
|
def fetch_krx_gold(self) -> Optional[float]:
|
||||||
|
"""금 시세 (네이버 금융 모바일)"""
|
||||||
|
try:
|
||||||
|
url = "https://m.stock.naver.com/marketindex/metals/M04020000"
|
||||||
|
res = requests.get(url, timeout=5)
|
||||||
|
m = re.search(r'\"closePrice\":\"([\d,]+)\"', res.text)
|
||||||
|
return float(m.group(1).replace(",", "")) if m else None
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_all(self) -> Dict[str, Dict]:
|
||||||
|
print(f"📊 [{time.strftime('%H:%M:%S')}] 수집 시작...")
|
||||||
|
|
||||||
|
# 1. 환율 먼저 수집 (계산의 핵심)
|
||||||
|
usd_krw = self.fetch_usd_krw()
|
||||||
|
|
||||||
|
# 2. 나머지 자산 수집
|
||||||
|
results = {
|
||||||
|
"XAU/USD": {"가격": self.fetch_investing_com("XAU/USD"), "단위": "USD/oz"},
|
||||||
|
"XAU/CNY": {"가격": self.fetch_investing_com("XAU/CNY"), "단위": "CNY/oz"},
|
||||||
|
"XAU/GBP": {"가격": self.fetch_investing_com("XAU/GBP"), "단위": "GBP/oz"},
|
||||||
|
"USD/DXY": {"가격": self.fetch_investing_com("USD/DXY"), "단위": "Index"},
|
||||||
|
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
|
||||||
|
"BTC/USD": {"가격": self.fetch_binance(), "단위": "USDT"},
|
||||||
|
"BTC/KRW": {"가격": self.fetch_upbit(), "단위": "KRW"},
|
||||||
|
"KRX/GLD": {"가격": self.fetch_krx_gold(), "단위": "KRW/g"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. XAU/KRW 계산
|
||||||
|
xau_krw = None
|
||||||
|
if results["XAU/USD"]["가격"] and usd_krw:
|
||||||
|
xau_krw = round((results["XAU/USD"]["가격"] / 31.1034768) * usd_krw, 0)
|
||||||
|
results["XAU/KRW"] = {"가격": xau_krw, "단위": "KRW/g"}
|
||||||
|
|
||||||
|
success_count = sum(1 for v in results.values() if v['가격'] is not None)
|
||||||
|
print(f"✅ 수집 완료 (성공: {success_count}/9)")
|
||||||
|
return results
|
||||||
|
|
||||||
|
fetcher = DataFetcher()
|
||||||
142
asset_pilot_docker/app/fetcher.py.claude
Normal file
142
asset_pilot_docker/app/fetcher.py.claude
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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()
|
||||||
55
asset_pilot_docker/app/models.py
Normal file
55
asset_pilot_docker/app/models.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class Asset(Base):
|
||||||
|
"""자산 마스터 테이블"""
|
||||||
|
__tablename__ = "assets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
symbol = Column(String(20), unique=True, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
category = Column(String(50)) # 귀금속, 암호화폐, 환율 등
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# 관계
|
||||||
|
user_assets = relationship("UserAsset", back_populates="asset")
|
||||||
|
price_history = relationship("PriceHistory", back_populates="asset")
|
||||||
|
|
||||||
|
class UserAsset(Base):
|
||||||
|
"""사용자 자산 정보"""
|
||||||
|
__tablename__ = "user_assets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False)
|
||||||
|
previous_close = Column(Float, default=0.0) # 전일종가
|
||||||
|
average_price = Column(Float, default=0.0) # 평균매입가
|
||||||
|
quantity = Column(Float, default=0.0) # 보유량
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# 관계
|
||||||
|
asset = relationship("Asset", back_populates="user_assets")
|
||||||
|
|
||||||
|
class PriceHistory(Base):
|
||||||
|
"""가격 히스토리 (선택적)"""
|
||||||
|
__tablename__ = "price_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# 관계
|
||||||
|
asset = relationship("Asset", back_populates="price_history")
|
||||||
|
|
||||||
|
class AlertSetting(Base):
|
||||||
|
"""알림 설정"""
|
||||||
|
__tablename__ = "alert_settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
setting_key = Column(String(100), unique=True, nullable=False)
|
||||||
|
setting_value = Column(Text) # JSON 형식으로 저장
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
67
asset_pilot_docker/docker-compose.yml
Normal file
67
asset_pilot_docker/docker-compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
# 1. PostgreSQL 데이터베이스
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: asset_pilot_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: asset_pilot
|
||||||
|
POSTGRES_USER: asset_user
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-assetpilot}
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./init-db:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- asset_pilot_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U asset_user -d asset_pilot"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# 2. Asset Pilot 웹 애플리케이션
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
# DNS 설정 (빌드 밖으로 이동)
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 1.1.1.1
|
||||||
|
container_name: asset_pilot_app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# DB 비밀번호 기본값을 postgres 서비스와 동일하게 'assetpilot'으로 설정
|
||||||
|
DATABASE_URL: postgresql://asset_user:${DB_PASSWORD:-assetpilot}@postgres:5432/asset_pilot
|
||||||
|
APP_HOST: 0.0.0.0
|
||||||
|
APP_PORT: 8000
|
||||||
|
DEBUG: "False"
|
||||||
|
FETCH_INTERVAL: 5
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
networks:
|
||||||
|
- asset_pilot_network
|
||||||
|
volumes:
|
||||||
|
- app_logs:/app/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
app_logs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
asset_pilot_network:
|
||||||
|
driver: bridge
|
||||||
74
asset_pilot_docker/import_csv.py
Executable file
74
asset_pilot_docker/import_csv.py
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
CSV 데이터 가져오기 스크립트 (Docker용)
|
||||||
|
Windows 앱의 user_assets.csv 파일을 PostgreSQL로 마이그레이션
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# app 모듈 import
|
||||||
|
from app.database import engine
|
||||||
|
from app.models import Asset, UserAsset
|
||||||
|
|
||||||
|
def import_csv(csv_file):
|
||||||
|
"""CSV 파일에서 데이터 가져오기"""
|
||||||
|
if not os.path.exists(csv_file):
|
||||||
|
print(f"❌ 파일을 찾을 수 없습니다: {csv_file}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"📁 CSV 파일 읽기: {csv_file}")
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
if len(row) != 4:
|
||||||
|
print(f"⚠️ 잘못된 행 형식 (무시): {row}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
asset_symbol, prev_close, avg_price, quantity = row
|
||||||
|
|
||||||
|
# 자산 찾기
|
||||||
|
asset = db.query(Asset).filter(Asset.symbol == asset_symbol).first()
|
||||||
|
if not asset:
|
||||||
|
print(f"⚠️ 자산을 찾을 수 없음: {asset_symbol}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 사용자 자산 업데이트
|
||||||
|
user_asset = db.query(UserAsset).filter(UserAsset.asset_id == asset.id).first()
|
||||||
|
if user_asset:
|
||||||
|
user_asset.previous_close = float(prev_close)
|
||||||
|
user_asset.average_price = float(avg_price)
|
||||||
|
user_asset.quantity = float(quantity)
|
||||||
|
count += 1
|
||||||
|
print(f"✓ {asset_symbol}: 전일={prev_close}, 평단={avg_price}, 수량={quantity}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 사용자 자산 정보 없음: {asset_symbol}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"\n✅ {count}개 항목 가져오기 완료!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 오류 발생: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("사용법: python import_csv.py <csv_file_path>")
|
||||||
|
print("예제: python import_csv.py user_assets.csv")
|
||||||
|
print("\nDocker에서 사용:")
|
||||||
|
print("1. docker cp user_assets.csv asset_pilot_app:/app/")
|
||||||
|
print("2. docker compose exec app python import_csv.py user_assets.csv")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
csv_file = sys.argv[1]
|
||||||
|
import_csv(csv_file)
|
||||||
128
asset_pilot_docker/init_db.py
Executable file
128
asset_pilot_docker/init_db.py
Executable file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
데이터베이스 초기화 스크립트
|
||||||
|
PostgreSQL 데이터베이스에 테이블을 생성하고 기본 데이터를 삽입합니다.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# 환경 변수 로드
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# app 모듈 import를 위한 경로 추가
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from app.models import Base, Asset, UserAsset, AlertSetting
|
||||||
|
from app.database import DATABASE_URL
|
||||||
|
import json
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""데이터베이스 초기화"""
|
||||||
|
print("🔧 데이터베이스 초기화 시작...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 엔진 생성
|
||||||
|
engine = create_engine(DATABASE_URL, echo=True)
|
||||||
|
|
||||||
|
# 테이블 생성
|
||||||
|
print("\n📋 테이블 생성 중...")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("✅ 테이블 생성 완료")
|
||||||
|
|
||||||
|
# 세션 생성
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 자산 마스터 데이터 초기화
|
||||||
|
print("\n📊 자산 마스터 데이터 초기화 중...")
|
||||||
|
assets_data = [
|
||||||
|
("XAU/USD", "금/달러", "귀금속"),
|
||||||
|
("XAU/CNY", "금/위안", "귀금속"),
|
||||||
|
("XAU/GBP", "금/파운드", "귀금속"),
|
||||||
|
("USD/DXY", "달러인덱스", "환율"),
|
||||||
|
("USD/KRW", "달러/원", "환율"),
|
||||||
|
("BTC/USD", "비트코인/달러", "암호화폐"),
|
||||||
|
("BTC/KRW", "비트코인/원", "암호화폐"),
|
||||||
|
("KRX/GLD", "금 현물", "귀금속"),
|
||||||
|
("XAU/KRW", "금/원", "귀금속"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for symbol, name, category in assets_data:
|
||||||
|
existing = db.query(Asset).filter(Asset.symbol == symbol).first()
|
||||||
|
if not existing:
|
||||||
|
asset = Asset(symbol=symbol, name=name, category=category)
|
||||||
|
db.add(asset)
|
||||||
|
print(f" ✓ {symbol} ({name}) 추가")
|
||||||
|
else:
|
||||||
|
print(f" - {symbol} 이미 존재")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("✅ 자산 마스터 데이터 초기화 완료")
|
||||||
|
|
||||||
|
# 사용자 자산 초기화
|
||||||
|
print("\n👤 사용자 자산 데이터 초기화 중...")
|
||||||
|
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)
|
||||||
|
print(f" ✓ {asset.symbol} 사용자 데이터 추가")
|
||||||
|
else:
|
||||||
|
print(f" - {asset.symbol} 사용자 데이터 이미 존재")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("✅ 사용자 자산 데이터 초기화 완료")
|
||||||
|
|
||||||
|
# 알림 설정 초기화
|
||||||
|
print("\n🔔 알림 설정 초기화 중...")
|
||||||
|
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)
|
||||||
|
print(f" ✓ {key}: {value}")
|
||||||
|
else:
|
||||||
|
print(f" - {key} 이미 존재")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("✅ 알림 설정 초기화 완료")
|
||||||
|
|
||||||
|
print("\n🎉 데이터베이스 초기화 완료!")
|
||||||
|
print("\n다음 명령으로 서버를 시작하세요:")
|
||||||
|
print(" python main.py")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 오류 발생: {e}")
|
||||||
|
print("\n문제 해결 방법:")
|
||||||
|
print("1. PostgreSQL이 실행 중인지 확인하세요:")
|
||||||
|
print(" sudo systemctl status postgresql")
|
||||||
|
print("\n2. 데이터베이스가 생성되었는지 확인하세요:")
|
||||||
|
print(" sudo -u postgres psql -c '\\l'")
|
||||||
|
print("\n3. .env 파일의 DATABASE_URL이 올바른지 확인하세요")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_database()
|
||||||
268
asset_pilot_docker/main.py
Normal file
268
asset_pilot_docker/main.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict
|
||||||
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from app.database import get_db, engine
|
||||||
|
from app.models import Base, Asset, UserAsset, AlertSetting
|
||||||
|
from app.fetcher import fetcher
|
||||||
|
from app.calculator import Calculator
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# 테이블 생성
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.0.0")
|
||||||
|
|
||||||
|
# 정적 파일 및 템플릿 설정
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# 전역 변수: 현재 가격 캐시
|
||||||
|
current_prices: Dict = {}
|
||||||
|
|
||||||
|
# ==================== Pydantic 모델 ====================
|
||||||
|
|
||||||
|
class UserAssetUpdate(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
previous_close: float
|
||||||
|
average_price: float
|
||||||
|
quantity: float
|
||||||
|
|
||||||
|
class AlertSettingUpdate(BaseModel):
|
||||||
|
settings: Dict
|
||||||
|
|
||||||
|
# ==================== 데이터베이스 초기화 ====================
|
||||||
|
|
||||||
|
def init_assets(db: Session):
|
||||||
|
"""자산 마스터 데이터 초기화"""
|
||||||
|
assets_data = [
|
||||||
|
("XAU/USD", "금/달러", "귀금속"),
|
||||||
|
("XAU/CNY", "금/위안", "귀금속"),
|
||||||
|
("XAU/GBP", "금/파운드", "귀금속"),
|
||||||
|
("USD/DXY", "달러인덱스", "환율"),
|
||||||
|
("USD/KRW", "달러/원", "환율"),
|
||||||
|
("BTC/USD", "비트코인/달러", "암호화폐"),
|
||||||
|
("BTC/KRW", "비트코인/원", "암호화폐"),
|
||||||
|
("KRX/GLD", "금 현물", "귀금속"),
|
||||||
|
("XAU/KRW", "금/원", "귀금속"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for symbol, name, category in assets_data:
|
||||||
|
existing = db.query(Asset).filter(Asset.symbol == symbol).first()
|
||||||
|
if not existing:
|
||||||
|
asset = Asset(symbol=symbol, name=name, category=category)
|
||||||
|
db.add(asset)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("✅ 자산 마스터 데이터 초기화 완료")
|
||||||
|
|
||||||
|
def init_user_assets(db: Session):
|
||||||
|
"""사용자 자산 초기화 (기본값 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:
|
||||||
|
init_assets(db)
|
||||||
|
init_user_assets(db)
|
||||||
|
init_alert_settings(db)
|
||||||
|
print("🚀 Asset Pilot 서버 시작 완료")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# 백그라운드 데이터 수집 시작
|
||||||
|
asyncio.create_task(background_fetch())
|
||||||
|
|
||||||
|
async def background_fetch():
|
||||||
|
"""백그라운드에서 주기적으로 가격 수집"""
|
||||||
|
global current_prices
|
||||||
|
interval = int(os.getenv('FETCH_INTERVAL', 5))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 데이터 수집 시작...")
|
||||||
|
current_prices = fetcher.fetch_all()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 데이터 수집 오류: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
# ==================== API 엔드포인트 ====================
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def read_root(request: Request):
|
||||||
|
"""메인 페이지"""
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
@app.get("/api/prices")
|
||||||
|
async def get_prices():
|
||||||
|
"""현재 가격 조회"""
|
||||||
|
return current_prices
|
||||||
|
|
||||||
|
@app.get("/api/assets")
|
||||||
|
async def get_assets(db: Session = Depends(get_db)):
|
||||||
|
"""사용자 자산 조회"""
|
||||||
|
assets = db.query(Asset, UserAsset).join(
|
||||||
|
UserAsset, Asset.id == UserAsset.asset_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for asset, user_asset in assets:
|
||||||
|
result.append({
|
||||||
|
"symbol": asset.symbol,
|
||||||
|
"name": asset.name,
|
||||||
|
"category": asset.category,
|
||||||
|
"previous_close": float(user_asset.previous_close),
|
||||||
|
"average_price": float(user_asset.average_price),
|
||||||
|
"quantity": float(user_asset.quantity),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/assets")
|
||||||
|
async def update_asset(data: UserAssetUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""자산 정보 업데이트"""
|
||||||
|
asset = db.query(Asset).filter(Asset.symbol == data.symbol).first()
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
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")
|
||||||
|
async def get_pnl(db: Session = Depends(get_db)):
|
||||||
|
"""손익 계산"""
|
||||||
|
# KRX/GLD 자산 정보
|
||||||
|
krx_asset = db.query(Asset).filter(Asset.symbol == "KRX/GLD").first()
|
||||||
|
krx_user = db.query(UserAsset).filter(UserAsset.asset_id == krx_asset.id).first() if krx_asset else None
|
||||||
|
|
||||||
|
# BTC/KRW 자산 정보
|
||||||
|
btc_asset = db.query(Asset).filter(Asset.symbol == "BTC/KRW").first()
|
||||||
|
btc_user = db.query(UserAsset).filter(UserAsset.asset_id == btc_asset.id).first() if btc_asset else None
|
||||||
|
|
||||||
|
gold_buy_price = float(krx_user.average_price) if krx_user else 0
|
||||||
|
gold_quantity = float(krx_user.quantity) if krx_user else 0
|
||||||
|
btc_buy_price = float(btc_user.average_price) if btc_user else 0
|
||||||
|
btc_quantity = float(btc_user.quantity) if btc_user else 0
|
||||||
|
|
||||||
|
current_gold = current_prices.get("KRX/GLD", {}).get("가격")
|
||||||
|
current_btc = current_prices.get("BTC/KRW", {}).get("가격")
|
||||||
|
|
||||||
|
pnl = Calculator.calc_pnl(
|
||||||
|
gold_buy_price, gold_quantity,
|
||||||
|
btc_buy_price, btc_quantity,
|
||||||
|
current_gold, current_btc
|
||||||
|
)
|
||||||
|
|
||||||
|
return pnl
|
||||||
|
|
||||||
|
@app.get("/api/alerts/settings")
|
||||||
|
async def get_alert_settings(db: Session = Depends(get_db)):
|
||||||
|
"""알림 설정 조회"""
|
||||||
|
settings = db.query(AlertSetting).all()
|
||||||
|
result = {}
|
||||||
|
for setting in settings:
|
||||||
|
try:
|
||||||
|
result[setting.setting_key] = json.loads(setting.setting_value)
|
||||||
|
except:
|
||||||
|
result[setting.setting_key] = setting.setting_value
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/alerts/settings")
|
||||||
|
async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""알림 설정 업데이트"""
|
||||||
|
for key, value in data.settings.items():
|
||||||
|
setting = db.query(AlertSetting).filter(AlertSetting.setting_key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.setting_value = json.dumps(value)
|
||||||
|
else:
|
||||||
|
new_setting = AlertSetting(setting_key=key, setting_value=json.dumps(value))
|
||||||
|
db.add(new_setting)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "success", "message": "알림 설정 업데이트 완료"}
|
||||||
|
|
||||||
|
@app.get("/api/stream")
|
||||||
|
async def stream_prices():
|
||||||
|
"""Server-Sent Events로 실시간 가격 스트리밍"""
|
||||||
|
async def event_generator():
|
||||||
|
while True:
|
||||||
|
if current_prices:
|
||||||
|
data = json.dumps(current_prices, ensure_ascii=False)
|
||||||
|
yield f"data: {data}\n\n"
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""헬스 체크"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"prices_loaded": len(current_prices) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== 메인 실행 ====================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=os.getenv("APP_HOST", "0.0.0.0"),
|
||||||
|
port=int(os.getenv("APP_PORT", 8000)),
|
||||||
|
reload=os.getenv("DEBUG", "False").lower() == "true"
|
||||||
|
)
|
||||||
12
asset_pilot_docker/requirements.txt
Normal file
12
asset_pilot_docker/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.5.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
jinja2==3.1.2
|
||||||
|
aiofiles==23.2.1
|
||||||
|
requests==2.31.0
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
lxml==4.9.3
|
||||||
|
python-multipart==0.0.6
|
||||||
117
asset_pilot_docker/start.sh
Executable file
117
asset_pilot_docker/start.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Asset Pilot Docker 빠른 시작 스크립트
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🐳 Asset Pilot Docker 설치를 시작합니다..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 색상 정의
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Docker 설치 확인
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠️ Docker가 설치되어 있지 않습니다.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Docker를 설치하시겠습니까? (y/n)"
|
||||||
|
read -r INSTALL_DOCKER
|
||||||
|
|
||||||
|
if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then
|
||||||
|
echo -e "${BLUE}🐳 Docker 설치 중...${NC}"
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
rm get-docker.sh
|
||||||
|
echo -e "${GREEN}✓ Docker 설치 완료${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}⚠️ 로그아웃 후 다시 로그인하거나 다음 명령을 실행하세요:${NC}"
|
||||||
|
echo " newgrp docker"
|
||||||
|
echo ""
|
||||||
|
echo "그런 다음 이 스크립트를 다시 실행하세요."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Docker가 필요합니다. 설치 후 다시 시도하세요.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Docker 확인 완료${NC}"
|
||||||
|
|
||||||
|
# Docker Compose 확인
|
||||||
|
if ! docker compose version &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}⚠️ Docker Compose가 설치되어 있지 않습니다.${NC}"
|
||||||
|
echo -e "${BLUE}📦 Docker Compose 설치 중...${NC}"
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y docker-compose-plugin
|
||||||
|
echo -e "${GREEN}✓ Docker Compose 설치 완료${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Docker Compose 확인 완료${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# .env 파일 설정
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo -e "${YELLOW}🔐 데이터베이스 비밀번호를 설정하세요${NC}"
|
||||||
|
echo -n "비밀번호 입력: "
|
||||||
|
read -s DB_PASSWORD
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cat > .env << EOF
|
||||||
|
# PostgreSQL 데이터베이스 비밀번호
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
|
||||||
|
# 선택적 설정
|
||||||
|
# FETCH_INTERVAL=5
|
||||||
|
# DEBUG=False
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 .env
|
||||||
|
echo -e "${GREEN}✓ .env 파일 생성 완료${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ .env 파일이 이미 존재합니다${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}🚀 Docker 컨테이너 빌드 및 실행 중...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Docker 이미지 빌드 및 컨테이너 시작
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}⏳ 데이터베이스 준비 대기 중...${NC}"
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 데이터베이스 초기화
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}🔧 데이터베이스 초기화 중...${NC}"
|
||||||
|
docker compose exec app python init_db.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ 설치 완료!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo -e "${GREEN}🌐 접속 정보${NC}"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
# IP 주소 확인
|
||||||
|
IP_ADDR=$(hostname -I | awk '{print $1}')
|
||||||
|
echo -e "로컬: ${BLUE}http://localhost:8000${NC}"
|
||||||
|
echo -e "네트워크: ${BLUE}http://${IP_ADDR}:8000${NC}"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "유용한 명령어:"
|
||||||
|
echo " docker compose ps # 컨테이너 상태 확인"
|
||||||
|
echo " docker compose logs -f # 로그 실시간 보기"
|
||||||
|
echo " docker compose restart # 재시작"
|
||||||
|
echo " docker compose down # 중지"
|
||||||
|
echo ""
|
||||||
|
echo "CSV 데이터 가져오기:"
|
||||||
|
echo " docker cp user_assets.csv asset_pilot_app:/app/"
|
||||||
|
echo " docker compose exec app python import_csv.py user_assets.csv"
|
||||||
|
echo ""
|
||||||
353
asset_pilot_docker/static/css/style.css
Normal file
353
asset_pilot_docker/static/css/style.css
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
header {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--success-color);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 손익 요약 */
|
||||||
|
.pnl-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-card.total {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, #1e40af 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-card h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-percent {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-value.profit {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-value.loss {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-card.total .pnl-value,
|
||||||
|
.pnl-card.total .pnl-percent {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 자산 섹션 */
|
||||||
|
.assets-section {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테이블 */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numeric {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
td input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-up {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-down {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모달 */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group input[type="number"] {
|
||||||
|
width: 120px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 푸터 */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
309
asset_pilot_docker/static/js/app.js
Normal file
309
asset_pilot_docker/static/js/app.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// 전역 변수
|
||||||
|
let currentPrices = {};
|
||||||
|
let userAssets = [];
|
||||||
|
let alertSettings = {};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadAssets();
|
||||||
|
loadAlertSettings();
|
||||||
|
startPriceStream();
|
||||||
|
|
||||||
|
// 이벤트 리스너
|
||||||
|
document.getElementById('refresh-btn').addEventListener('click', refreshData);
|
||||||
|
document.getElementById('alert-settings-btn').addEventListener('click', openAlertModal);
|
||||||
|
document.querySelector('.close').addEventListener('click', closeAlertModal);
|
||||||
|
document.getElementById('save-alerts').addEventListener('click', saveAlertSettings);
|
||||||
|
document.getElementById('cancel-alerts').addEventListener('click', closeAlertModal);
|
||||||
|
|
||||||
|
// 주기적 PnL 업데이트
|
||||||
|
setInterval(updatePnL, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자산 데이터 로드
|
||||||
|
async function loadAssets() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/assets');
|
||||||
|
userAssets = await response.json();
|
||||||
|
renderAssetsTable();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자산 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 설정 로드
|
||||||
|
async function loadAlertSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/alerts/settings');
|
||||||
|
alertSettings = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 설정 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실시간 가격 스트리밍
|
||||||
|
function startPriceStream() {
|
||||||
|
const eventSource = new EventSource('/api/stream');
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
currentPrices = JSON.parse(event.data);
|
||||||
|
updatePricesInTable();
|
||||||
|
updateLastUpdateTime();
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.error('SSE 연결 오류');
|
||||||
|
document.getElementById('status-indicator').style.backgroundColor = '#ef4444';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 렌더링
|
||||||
|
function renderAssetsTable() {
|
||||||
|
const tbody = document.getElementById('assets-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
const assets = [
|
||||||
|
'XAU/USD', 'XAU/CNY', 'XAU/GBP', 'USD/DXY', 'USD/KRW',
|
||||||
|
'BTC/USD', 'BTC/KRW', 'KRX/GLD', 'XAU/KRW'
|
||||||
|
];
|
||||||
|
|
||||||
|
assets.forEach(symbol => {
|
||||||
|
const asset = userAssets.find(a => a.symbol === symbol);
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.dataset.symbol = symbol;
|
||||||
|
|
||||||
|
const decimalPlaces = symbol.includes('BTC') ? 8 : 2;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><strong>${symbol}</strong></td>
|
||||||
|
<td class="numeric">
|
||||||
|
<input type="number"
|
||||||
|
class="prev-close"
|
||||||
|
value="${asset.previous_close}"
|
||||||
|
step="0.01"
|
||||||
|
data-symbol="${symbol}">
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 입력 필드 이벤트 리스너
|
||||||
|
document.querySelectorAll('input[type="number"]').forEach(input => {
|
||||||
|
input.addEventListener('change', handleAssetChange);
|
||||||
|
input.addEventListener('blur', handleAssetChange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블에 가격 업데이트
|
||||||
|
function updatePricesInTable() {
|
||||||
|
const rows = document.querySelectorAll('#assets-tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const symbol = row.dataset.symbol;
|
||||||
|
const priceData = currentPrices[symbol];
|
||||||
|
|
||||||
|
if (!priceData || !priceData.가격) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrice = priceData.가격;
|
||||||
|
const prevClose = parseFloat(row.querySelector('.prev-close').value) || 0;
|
||||||
|
|
||||||
|
// 현재가 표시
|
||||||
|
const decimalPlaces = symbol.includes('USD') || symbol.includes('DXY') ? 2 : 0;
|
||||||
|
row.querySelector('.current-price').textContent = formatNumber(currentPrice, decimalPlaces);
|
||||||
|
|
||||||
|
// 변동 계산
|
||||||
|
const change = currentPrice - prevClose;
|
||||||
|
const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0;
|
||||||
|
|
||||||
|
const changeCell = row.querySelector('.change');
|
||||||
|
const changePercentCell = row.querySelector('.change-percent');
|
||||||
|
|
||||||
|
changeCell.textContent = formatNumber(change, decimalPlaces);
|
||||||
|
changePercentCell.textContent = `${formatNumber(changePercent, 2)}%`;
|
||||||
|
|
||||||
|
// 색상 적용
|
||||||
|
const colorClass = change > 0 ? 'price-up' : change < 0 ? 'price-down' : '';
|
||||||
|
changeCell.className = `numeric ${colorClass}`;
|
||||||
|
changePercentCell.className = `numeric ${colorClass}`;
|
||||||
|
|
||||||
|
// 매입액 계산
|
||||||
|
const avgPrice = parseFloat(row.querySelector('.avg-price').value) || 0;
|
||||||
|
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
|
||||||
|
const buyTotal = avgPrice * quantity;
|
||||||
|
row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 손익 업데이트
|
||||||
|
async function updatePnL() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pnl');
|
||||||
|
const pnl = await response.json();
|
||||||
|
|
||||||
|
// 금 손익
|
||||||
|
updatePnLCard('gold-pnl', 'gold-percent', pnl.금손익, pnl['금손익%']);
|
||||||
|
|
||||||
|
// BTC 손익
|
||||||
|
updatePnLCard('btc-pnl', 'btc-percent', pnl.BTC손익, pnl['BTC손익%']);
|
||||||
|
|
||||||
|
// 총 손익
|
||||||
|
updatePnLCard('total-pnl', 'total-percent', pnl.총손익, pnl['총손익%']);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PnL 업데이트 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PnL 카드 업데이트
|
||||||
|
function updatePnLCard(valueId, percentId, value, percent) {
|
||||||
|
const valueElem = document.getElementById(valueId);
|
||||||
|
const percentElem = document.getElementById(percentId);
|
||||||
|
|
||||||
|
valueElem.textContent = formatNumber(value, 0) + ' 원';
|
||||||
|
percentElem.textContent = formatNumber(percent, 2) + '%';
|
||||||
|
|
||||||
|
// 총손익이 아닌 경우만 색상 적용
|
||||||
|
if (valueId !== 'total-pnl') {
|
||||||
|
valueElem.className = `pnl-value ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`;
|
||||||
|
percentElem.className = `pnl-percent ${value > 0 ? 'profit' : value < 0 ? 'loss' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자산 변경 처리
|
||||||
|
async function handleAssetChange(event) {
|
||||||
|
const input = event.target;
|
||||||
|
const symbol = input.dataset.symbol;
|
||||||
|
const row = input.closest('tr');
|
||||||
|
|
||||||
|
const previousClose = parseFloat(row.querySelector('.prev-close').value) || 0;
|
||||||
|
const averagePrice = parseFloat(row.querySelector('.avg-price').value) || 0;
|
||||||
|
const quantity = parseFloat(row.querySelector('.quantity').value) || 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/assets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
symbol,
|
||||||
|
previous_close: previousClose,
|
||||||
|
average_price: averagePrice,
|
||||||
|
quantity: quantity
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`✅ ${symbol} 업데이트 완료`);
|
||||||
|
// 매입액 즉시 업데이트
|
||||||
|
const buyTotal = averagePrice * quantity;
|
||||||
|
row.querySelector('.buy-total').textContent = formatNumber(buyTotal, 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('업데이트 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 설정 모달 열기
|
||||||
|
function openAlertModal() {
|
||||||
|
document.getElementById('급등락_감지').checked = alertSettings.급등락_감지 || false;
|
||||||
|
document.getElementById('급등락_임계값').value = alertSettings.급등락_임계값 || 3.0;
|
||||||
|
document.getElementById('목표수익률_감지').checked = alertSettings.목표수익률_감지 || false;
|
||||||
|
document.getElementById('목표수익률').value = alertSettings.목표수익률 || 10.0;
|
||||||
|
document.getElementById('특정가격_감지').checked = alertSettings.특정가격_감지 || false;
|
||||||
|
document.getElementById('금_목표가격').value = alertSettings.금_목표가격 || 100000;
|
||||||
|
document.getElementById('BTC_목표가격').value = alertSettings.BTC_목표가격 || 100000000;
|
||||||
|
|
||||||
|
document.getElementById('alert-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 설정 모달 닫기
|
||||||
|
function closeAlertModal() {
|
||||||
|
document.getElementById('alert-modal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알림 설정 저장
|
||||||
|
async function saveAlertSettings() {
|
||||||
|
const settings = {
|
||||||
|
급등락_감지: document.getElementById('급등락_감지').checked,
|
||||||
|
급등락_임계값: parseFloat(document.getElementById('급등락_임계값').value),
|
||||||
|
목표수익률_감지: document.getElementById('목표수익률_감지').checked,
|
||||||
|
목표수익률: parseFloat(document.getElementById('목표수익률').value),
|
||||||
|
특정가격_감지: document.getElementById('특정가격_감지').checked,
|
||||||
|
금_목표가격: parseInt(document.getElementById('금_목표가격').value),
|
||||||
|
BTC_목표가격: parseInt(document.getElementById('BTC_목표가격').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/alerts/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ settings })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alertSettings = settings;
|
||||||
|
console.log('✅ 알림 설정 저장 완료');
|
||||||
|
closeAlertModal();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 설정 저장 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 새로고침
|
||||||
|
async function refreshData() {
|
||||||
|
await loadAssets();
|
||||||
|
console.log('🔄 데이터 새로고침 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마지막 업데이트 시간 표시
|
||||||
|
function updateLastUpdateTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const timeString = now.toLocaleTimeString('ko-KR');
|
||||||
|
document.getElementById('last-update').textContent = `마지막 업데이트: ${timeString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 포맷팅
|
||||||
|
function formatNumber(value, decimals = 0) {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
return value.toLocaleString('ko-KR', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 클릭 시 모달 닫기
|
||||||
|
window.addEventListener('click', (event) => {
|
||||||
|
const modal = document.getElementById('alert-modal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeAlertModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
136
asset_pilot_docker/templates/index.html
Normal file
136
asset_pilot_docker/templates/index.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Asset Pilot - 자산 모니터</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>💰 Asset Pilot</h1>
|
||||||
|
<p class="subtitle">실시간 자산 모니터링 시스템</p>
|
||||||
|
<div class="status-bar">
|
||||||
|
<span id="status-indicator" class="status-dot"></span>
|
||||||
|
<span id="last-update">데이터 수집 중...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- 손익 요약 -->
|
||||||
|
<section class="pnl-summary">
|
||||||
|
<div class="pnl-card">
|
||||||
|
<h3>금 현물</h3>
|
||||||
|
<div class="pnl-value" id="gold-pnl">N/A</div>
|
||||||
|
<div class="pnl-percent" id="gold-percent">N/A</div>
|
||||||
|
</div>
|
||||||
|
<div class="pnl-card">
|
||||||
|
<h3>비트코인</h3>
|
||||||
|
<div class="pnl-value" id="btc-pnl">N/A</div>
|
||||||
|
<div class="pnl-percent" id="btc-percent">N/A</div>
|
||||||
|
</div>
|
||||||
|
<div class="pnl-card total">
|
||||||
|
<h3>총 손익</h3>
|
||||||
|
<div class="pnl-value" id="total-pnl">N/A</div>
|
||||||
|
<div class="pnl-percent" id="total-percent">N/A</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 자산 테이블 -->
|
||||||
|
<section class="assets-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>📊 자산 현황</h2>
|
||||||
|
<button id="refresh-btn" class="btn btn-primary">새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="assets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>항목</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="assets-tbody">
|
||||||
|
<!-- 동적으로 생성 -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 알림 설정 모달 -->
|
||||||
|
<div id="alert-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🔔 알림 설정</h2>
|
||||||
|
<span class="close">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>급등/급락 알림</h3>
|
||||||
|
<label>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="alert-settings-btn" class="btn btn-secondary">⚙️ 알림 설정</button>
|
||||||
|
<p>Asset Pilot v1.0 - Orange Pi Edition</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user