AssetPilot OrangePi 5 Pluse Server-First Commit

This commit is contained in:
Wind
2026-02-10 12:23:22 +09:00
commit bf87175f51
45 changed files with 4060 additions and 0 deletions

View 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` 참조

View 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 가상환경 생성
- [ ] 의존성 패키지 설치
- [ ] 환경 변수 설정
- [ ] 데이터베이스 초기화
- [ ] 서비스 등록 및 시작
- [ ] 웹 브라우저 접속 확인
- [ ] 데이터 수집 동작 확인
- [ ] 기존 데이터 마이그레이션 (선택적)
모든 항목 완료 시 시스템 사용 준비 완료! 🎉

View 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 (프로젝트 내)
---
**준비 완료! 이제 자산을 모니터링하세요! 💰**

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View 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 없는 환경
✅ 최소 리소스 사용
✅ 직접 제어 선호

View 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
View File

@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
postgres_data/ # DB 데이터는 따로 관리
.env # 비밀번호 등 민감 정보

59
asset_pilot_docker/.gitignore vendored Normal file
View 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

View 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"

View 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

View 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

View 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

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

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

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

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

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

View File

@@ -0,0 +1 @@
python3

View File

@@ -0,0 +1 @@
/usr/bin/python3

View File

@@ -0,0 +1 @@
python3

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

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

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

View File

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

View File

@@ -0,0 +1 @@
lib

View 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

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

View 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"]

View 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**

View 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

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

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

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

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

View 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

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

View 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
View 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 ""

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

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

View 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">&times;</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>