be,fe
This commit is contained in:
parent
fff3166d48
commit
9ba0b349ce
|
|
@ -1,8 +1,120 @@
|
|||
env/
|
||||
# 환경 변수 파일
|
||||
.env
|
||||
web/backend/.env
|
||||
*.env
|
||||
env_local.txt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
*.csv
|
||||
src/
|
||||
eval_results/
|
||||
eval_data/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/staticfiles/
|
||||
/media/
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Node.js (React)
|
||||
web/frontend/node_modules/
|
||||
web/frontend/build/
|
||||
web/frontend/dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage reports
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Trading specific
|
||||
trading_data/
|
||||
analysis_results/
|
||||
temp_files/
|
||||
295
README.md
295
README.md
|
|
@ -211,3 +211,298 @@ Please reference our work if you find *TradingAgents* provides you with some hel
|
|||
url={https://arxiv.org/abs/2412.20138},
|
||||
}
|
||||
```
|
||||
|
||||
# TradingAgents Web Application
|
||||
|
||||
CLI 기능을 웹에서 사용할 수 있는 React + Django 웹 애플리케이션입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
1. **사용자 인증**
|
||||
- JWT 기반 로그인/회원가입
|
||||
- OpenAI API 키 관리 (암호화 저장)
|
||||
- 개발자 기본 키 fallback
|
||||
|
||||
2. **거래 분석**
|
||||
- CLI의 모든 분석 기능을 웹에서 사용
|
||||
- 실시간 분석 진행 상황 (WebSocket)
|
||||
- 분석 기록 관리
|
||||
|
||||
3. **사용자 경험**
|
||||
- 현대적인 React UI (Ant Design)
|
||||
- 반응형 디자인
|
||||
- 실시간 업데이트
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### 백엔드
|
||||
- **Django 4.2** - 웹 프레임워크
|
||||
- **Django REST Framework** - API 개발
|
||||
- **Django Channels** - WebSocket 지원
|
||||
- **MySQL 8.0** - 데이터베이스 (Docker)
|
||||
- **Redis 7** - WebSocket 메시지 브로커 (Docker)
|
||||
- **JWT** - 인증
|
||||
|
||||
### 프론트엔드
|
||||
- **React 18** - UI 라이브러리
|
||||
- **Ant Design** - UI 컴포넌트
|
||||
- **Styled Components** - 스타일링
|
||||
- **Axios** - HTTP 클라이언트
|
||||
- **WebSocket** - 실시간 통신
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
### 1. 환경 설정
|
||||
|
||||
```bash
|
||||
# 가상환경 생성 및 활성화
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# Python 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Node.js 의존성 설치
|
||||
cd web/frontend
|
||||
npm install
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 및 Redis 설정 (Docker)
|
||||
|
||||
Docker와 Docker Compose를 이용해 MySQL과 Redis를 실행합니다.
|
||||
|
||||
```bash
|
||||
# Docker 및 Docker Compose 설치 확인
|
||||
docker --version
|
||||
docker-compose --version
|
||||
|
||||
# 편의 스크립트 사용 (권장)
|
||||
chmod +x scripts/docker-commands.sh
|
||||
./scripts/docker-commands.sh start
|
||||
|
||||
# 또는 직접 Docker Compose 명령 사용
|
||||
docker-compose up -d mysql redis
|
||||
|
||||
# phpMyAdmin도 함께 시작 (데이터베이스 관리용)
|
||||
./scripts/docker-commands.sh start-all
|
||||
|
||||
# 컨테이너 상태 확인
|
||||
./scripts/docker-commands.sh status
|
||||
```
|
||||
|
||||
### 3. 환경 변수 설정
|
||||
|
||||
`web/backend/.env` 파일을 생성합니다. `env_example.txt`를 참고하여 설정하세요:
|
||||
|
||||
```bash
|
||||
# 예시 파일을 복사하여 시작
|
||||
cp web/backend/env_example.txt web/backend/.env
|
||||
|
||||
# .env 파일을 편집하여 실제 값들로 변경
|
||||
nano web/backend/.env # 또는 다른 텍스트 에디터 사용
|
||||
```
|
||||
|
||||
주요 설정값들:
|
||||
|
||||
```env
|
||||
# Django 설정
|
||||
SECRET_KEY=your-secret-key-here-change-this-to-a-random-string
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# MySQL 데이터베이스 설정 (Docker)
|
||||
DB_NAME=tradingagents_web
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your-mysql-password-here
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
|
||||
# Redis 설정 (Docker)
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
|
||||
# OpenAI API 키 (개발자 기본 키)
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
```
|
||||
|
||||
### 4. 데이터베이스 마이그레이션
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser # 관리자 계정 생성
|
||||
```
|
||||
|
||||
### 5. 개발 서버 실행
|
||||
|
||||
**터미널 1 - Docker 컨테이너 (MySQL + Redis):**
|
||||
```bash
|
||||
# 백그라운드에서 실행
|
||||
docker-compose up -d mysql redis
|
||||
|
||||
# 또는 포그라운드에서 로그 확인
|
||||
docker-compose up mysql redis
|
||||
```
|
||||
|
||||
**터미널 2 - Django 백엔드:**
|
||||
```bash
|
||||
cd web/backend
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**터미널 3 - React 프론트엔드:**
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
## 접속 정보
|
||||
|
||||
- **프론트엔드**: http://localhost:3000
|
||||
- **백엔드 API**: http://localhost:8000
|
||||
- **Django Admin**: http://localhost:8000/admin
|
||||
- **phpMyAdmin** (선택사항): http://localhost:8080
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 인증
|
||||
- `POST /api/auth/register/` - 회원가입
|
||||
- `POST /api/auth/login/` - 로그인
|
||||
- `GET /api/auth/user/` - 사용자 정보
|
||||
- `PUT /api/auth/profile/` - 프로필 수정
|
||||
- `POST /api/auth/check-api-key/` - API 키 검증
|
||||
|
||||
### 거래 분석
|
||||
- `GET /api/trading/config/` - 분석 설정 정보
|
||||
- `POST /api/trading/start/` - 분석 시작
|
||||
- `GET /api/trading/status/{id}/` - 분석 상태 조회
|
||||
- `GET /api/trading/history/` - 분석 기록
|
||||
- `GET /api/trading/report/{id}/` - 분석 보고서
|
||||
|
||||
### WebSocket
|
||||
- `ws://localhost:8000/ws/trading-analysis/` - 실시간 분석 업데이트
|
||||
|
||||
## OpenAI API 키 관리
|
||||
|
||||
1. **사용자 개별 키**: 사용자가 프로필에서 설정한 개인 OpenAI API 키
|
||||
2. **개발자 기본 키**: `.env` 파일의 `OPENAI_API_KEY` (사용자 키가 없을 때 사용)
|
||||
3. **보안**: 사용자 키는 암호화되어 데이터베이스에 저장
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
├── cli/ # 기존 CLI 코드
|
||||
├── web/
|
||||
│ ├── backend/ # Django 백엔드
|
||||
│ │ ├── tradingagents_web/ # 프로젝트 설정
|
||||
│ │ └── apps/ # Django 앱들
|
||||
│ │ ├── authentication/ # 사용자 인증
|
||||
│ │ ├── trading_api/ # 거래 분석 API
|
||||
│ │ └── websocket/ # WebSocket 처리
|
||||
│ └── frontend/ # React 프론트엔드
|
||||
│ ├── public/
|
||||
│ └── src/
|
||||
│ ├── components/ # 재사용 컴포넌트
|
||||
│ ├── contexts/ # React Context
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ ├── services/ # API 서비스
|
||||
│ └── styles/ # 스타일 관련
|
||||
└── requirements.txt # Python 의존성
|
||||
```
|
||||
|
||||
## 개발 가이드
|
||||
|
||||
### 새로운 분석 기능 추가
|
||||
|
||||
1. `apps/trading_api/services.py`에 새로운 서비스 추가
|
||||
2. `apps/trading_api/views.py`에 새로운 뷰 추가
|
||||
3. `apps/trading_api/urls.py`에 URL 패턴 추가
|
||||
4. 프론트엔드에서 해당 API 호출
|
||||
|
||||
### 새로운 페이지 추가
|
||||
|
||||
1. `src/pages/` 디렉토리에 새 페이지 컴포넌트 생성
|
||||
2. `src/App.js`에 라우트 추가
|
||||
3. 필요한 경우 레이아웃의 메뉴에 추가
|
||||
|
||||
## 배포
|
||||
|
||||
### Docker Compose (권장)
|
||||
|
||||
```bash
|
||||
# 모든 서비스를 한 번에 시작 (개발 환경)
|
||||
docker-compose up -d
|
||||
|
||||
# 특정 서비스만 시작
|
||||
docker-compose up -d mysql redis
|
||||
|
||||
# 프로덕션 환경에서는 별도의 docker-compose.prod.yml 사용 권장
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 수동 배포
|
||||
|
||||
1. **프론트엔드 빌드**:
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **Django 정적 파일 수집**:
|
||||
```bash
|
||||
cd web/backend
|
||||
python manage.py collectstatic
|
||||
```
|
||||
|
||||
3. **프로덕션 서버 설정** (Nginx + Gunicorn + Daphne)
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
1. **Docker 컨테이너 관련**
|
||||
```bash
|
||||
# 컨테이너 상태 확인
|
||||
docker-compose ps
|
||||
|
||||
# 컨테이너 로그 확인
|
||||
docker-compose logs mysql
|
||||
docker-compose logs redis
|
||||
|
||||
# 컨테이너 재시작
|
||||
docker-compose restart mysql redis
|
||||
```
|
||||
|
||||
2. **WebSocket 연결 실패**
|
||||
- Redis 컨테이너가 실행 중인지 확인: `docker-compose ps`
|
||||
- 방화벽 설정 확인
|
||||
|
||||
3. **API 키 관련 오류**
|
||||
- `.env` 파일의 `OPENAI_API_KEY` 확인
|
||||
- 사용자 프로필에서 API 키 재설정
|
||||
|
||||
4. **데이터베이스 연결 오류**
|
||||
- MySQL 컨테이너 상태 확인: `docker-compose logs mysql`
|
||||
- `.env` 파일의 데이터베이스 연결 정보 확인
|
||||
- 컨테이너 포트 충돌 확인: `docker port tradingagents_mysql`
|
||||
|
||||
5. **MySQL 컨테이너 초기화 문제**
|
||||
```bash
|
||||
# 볼륨 삭제 후 재시작 (데이터 손실 주의!)
|
||||
docker-compose down -v
|
||||
docker-compose up -d mysql redis
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
이 프로젝트는 기존 TradingAgents 프로젝트의 라이선스를 따릅니다.
|
||||
|
||||
## 기여
|
||||
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: tradingagents_mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-password}
|
||||
MYSQL_DATABASE: ${DB_NAME:-tradingagents_db}
|
||||
MYSQL_USER: ${DB_USER:-tradinguser}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-password}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- /home/hskim/mysql_data:/var/lib/mysql
|
||||
- /home/hskim/docker/mysql/init:/docker-entrypoint-initdb.d
|
||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
networks:
|
||||
- tradingagents_network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tradingagents_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
networks:
|
||||
- tradingagents_network
|
||||
|
||||
# 개발용 phpMyAdmin (선택사항)
|
||||
# phpmyadmin:
|
||||
# image: phpmyadmin/phpmyadmin
|
||||
# container_name: tradingagents_phpmyadmin
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# PMA_HOST: mysql
|
||||
# PMA_PORT: 3306
|
||||
# PMA_USER: root
|
||||
# PMA_PASSWORD: ${DB_PASSWORD:-password}
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
# depends_on:
|
||||
# - mysql
|
||||
# networks:
|
||||
# - tradingagents_network
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
tradingagents_network:
|
||||
driver: bridge
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,22 @@
|
|||
# Backend dependencies - Django
|
||||
Django==4.2.7
|
||||
django-cors-headers==4.3.1
|
||||
django-rest-framework==0.1.0
|
||||
djangorestframework==3.14.0
|
||||
djangorestframework-simplejwt==5.3.0
|
||||
python-decouple==3.8
|
||||
cryptography==41.0.7
|
||||
mysqlclient==2.2.0
|
||||
channels==4.0.0
|
||||
channels-redis
|
||||
|
||||
# Existing CLI dependencies
|
||||
typer
|
||||
|
||||
questionary
|
||||
pydantic
|
||||
# OpenAI and other AI dependencies
|
||||
openai
|
||||
typing-extensions
|
||||
langchain-openai
|
||||
langchain-experimental
|
||||
|
|
@ -7,7 +26,6 @@ praw
|
|||
feedparser
|
||||
stockstats
|
||||
eodhd
|
||||
langgraph
|
||||
chromadb
|
||||
setuptools
|
||||
backtrader
|
||||
|
|
@ -22,3 +40,4 @@ redis
|
|||
chainlit
|
||||
rich
|
||||
questionary
|
||||
langgraph==0.4.8
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
#!/bin/bash
|
||||
|
||||
# TradingAgents Docker 관리 스크립트
|
||||
# 사용법: ./scripts/docker-commands.sh [command]
|
||||
|
||||
case "$1" in
|
||||
"start")
|
||||
echo "🚀 MySQL과 Redis 컨테이너를 시작합니다..."
|
||||
docker-compose up -d mysql redis
|
||||
echo "✅ 컨테이너가 시작되었습니다."
|
||||
docker-compose ps
|
||||
;;
|
||||
|
||||
"start-all")
|
||||
echo "🚀 모든 서비스(MySQL, Redis, phpMyAdmin)를 시작합니다..."
|
||||
docker-compose up -d
|
||||
echo "✅ 모든 컨테이너가 시작되었습니다."
|
||||
docker-compose ps
|
||||
;;
|
||||
|
||||
"stop")
|
||||
echo "🛑 모든 컨테이너를 중지합니다..."
|
||||
docker-compose down
|
||||
echo "✅ 컨테이너가 중지되었습니다."
|
||||
;;
|
||||
|
||||
"restart")
|
||||
echo "🔄 컨테이너를 재시작합니다..."
|
||||
docker-compose restart mysql redis
|
||||
echo "✅ 컨테이너가 재시작되었습니다."
|
||||
;;
|
||||
|
||||
"logs")
|
||||
echo "📋 컨테이너 로그를 확인합니다..."
|
||||
if [ -n "$2" ]; then
|
||||
docker-compose logs -f "$2"
|
||||
else
|
||||
docker-compose logs -f
|
||||
fi
|
||||
;;
|
||||
|
||||
"status")
|
||||
echo "📊 컨테이너 상태를 확인합니다..."
|
||||
docker-compose ps
|
||||
echo ""
|
||||
echo "🔍 포트 정보:"
|
||||
docker port tradingagents_mysql 2>/dev/null || echo "MySQL 컨테이너가 실행 중이지 않습니다."
|
||||
docker port tradingagents_redis 2>/dev/null || echo "Redis 컨테이너가 실행 중이지 않습니다."
|
||||
;;
|
||||
|
||||
"clean")
|
||||
echo "🧹 사용하지 않는 Docker 리소스를 정리합니다..."
|
||||
docker system prune -f
|
||||
echo "✅ 정리가 완료되었습니다."
|
||||
;;
|
||||
|
||||
"reset")
|
||||
echo "⚠️ 경고: 모든 데이터가 삭제됩니다!"
|
||||
read -p "정말로 데이터베이스를 초기화하시겠습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "🗑️ 볼륨과 함께 컨테이너를 삭제합니다..."
|
||||
docker-compose down -v
|
||||
echo "🚀 새로운 컨테이너를 시작합니다..."
|
||||
docker-compose up -d mysql redis
|
||||
echo "✅ 데이터베이스가 초기화되었습니다."
|
||||
else
|
||||
echo "❌ 취소되었습니다."
|
||||
fi
|
||||
;;
|
||||
|
||||
"mysql")
|
||||
echo "🔗 MySQL 컨테이너에 연결합니다..."
|
||||
docker-compose exec mysql mysql -u root -p tradingagents_web
|
||||
;;
|
||||
|
||||
"redis")
|
||||
echo "🔗 Redis 컨테이너에 연결합니다..."
|
||||
docker-compose exec redis redis-cli
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "🐳 TradingAgents Docker 관리 스크립트"
|
||||
echo ""
|
||||
echo "사용법: $0 [command]"
|
||||
echo ""
|
||||
echo "명령어:"
|
||||
echo " start - MySQL과 Redis 컨테이너 시작"
|
||||
echo " start-all - 모든 서비스 시작 (phpMyAdmin 포함)"
|
||||
echo " stop - 모든 컨테이너 중지"
|
||||
echo " restart - MySQL과 Redis 재시작"
|
||||
echo " logs - 컨테이너 로그 확인 (logs [service_name])"
|
||||
echo " status - 컨테이너 상태 확인"
|
||||
echo " clean - 사용하지 않는 Docker 리소스 정리"
|
||||
echo " reset - 데이터베이스 초기화 (주의: 모든 데이터 삭제)"
|
||||
echo " mysql - MySQL 컨테이너에 직접 연결"
|
||||
echo " redis - Redis 컨테이너에 직접 연결"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 start"
|
||||
echo " $0 logs mysql"
|
||||
echo " $0 status"
|
||||
;;
|
||||
esac
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
|||
# Apps package
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Authentication app
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User, UserProfile, AnalysisSession
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
"""사용자 프로필 인라인"""
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = '프로필'
|
||||
fields = ('default_ticker', 'preferred_research_depth', 'preferred_shallow_thinker', 'preferred_deep_thinker', 'has_openai_api_key')
|
||||
readonly_fields = ('has_openai_api_key',)
|
||||
|
||||
def has_openai_api_key(self, obj):
|
||||
return obj.has_openai_api_key()
|
||||
has_openai_api_key.boolean = True
|
||||
has_openai_api_key.short_description = 'OpenAI API 키 보유'
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""사용자 관리자"""
|
||||
inlines = (UserProfileInline,)
|
||||
list_display = ('email', 'username', 'first_name', 'last_name', 'is_staff', 'date_joined')
|
||||
list_filter = ('is_staff', 'is_superuser', 'is_active', 'date_joined')
|
||||
search_fields = ('email', 'username', 'first_name', 'last_name')
|
||||
ordering = ('email',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('email', 'password')}),
|
||||
('개인정보', {'fields': ('first_name', 'last_name', 'username')}),
|
||||
('권한', {
|
||||
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
|
||||
}),
|
||||
('중요한 날짜', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'username', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
"""사용자 프로필 관리자"""
|
||||
list_display = ('user', 'default_ticker', 'preferred_research_depth', 'has_openai_api_key', 'created_at')
|
||||
list_filter = ('preferred_research_depth', 'created_at')
|
||||
search_fields = ('user__email', 'user__username', 'default_ticker')
|
||||
readonly_fields = ('created_at', 'updated_at', 'has_openai_api_key')
|
||||
|
||||
fields = (
|
||||
'user', 'default_ticker', 'preferred_research_depth',
|
||||
'preferred_shallow_thinker', 'preferred_deep_thinker',
|
||||
'has_openai_api_key', 'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
def has_openai_api_key(self, obj):
|
||||
return obj.has_openai_api_key()
|
||||
has_openai_api_key.boolean = True
|
||||
has_openai_api_key.short_description = 'OpenAI API 키 보유'
|
||||
|
||||
|
||||
@admin.register(AnalysisSession)
|
||||
class AnalysisSessionAdmin(admin.ModelAdmin):
|
||||
"""분석 세션 관리자"""
|
||||
list_display = ('user', 'ticker', 'analysis_date', 'status', 'created_at', 'duration')
|
||||
list_filter = ('status', 'analysis_date', 'created_at')
|
||||
search_fields = ('user__email', 'user__username', 'ticker')
|
||||
readonly_fields = ('created_at', 'duration')
|
||||
|
||||
fields = (
|
||||
'user', 'ticker', 'analysis_date',
|
||||
'analysts_selected', 'research_depth', 'shallow_thinker', 'deep_thinker',
|
||||
'status', 'final_report', 'error_message',
|
||||
'created_at', 'started_at', 'completed_at', 'duration'
|
||||
)
|
||||
|
||||
def duration(self, obj):
|
||||
if obj.started_at and obj.completed_at:
|
||||
duration = obj.completed_at - obj.started_at
|
||||
return f"{int(duration.total_seconds())}초"
|
||||
return "미완료"
|
||||
duration.short_description = '소요시간'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.authentication'
|
||||
verbose_name = '사용자 인증'
|
||||
|
||||
def ready(self):
|
||||
import apps.authentication.signals
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# Generated by Django 4.2.7 on 2025-06-13 05:07
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('encrypted_openai_api_key', models.TextField(blank=True, null=True)),
|
||||
('default_ticker', models.CharField(default='SPY', max_length=10)),
|
||||
('preferred_research_depth', models.IntegerField(choices=[(1, 'Shallow'), (3, 'Medium'), (5, 'Deep')], default=3)),
|
||||
('preferred_shallow_thinker', models.CharField(default='gpt-4o-mini', max_length=50)),
|
||||
('preferred_deep_thinker', models.CharField(default='gpt-4o', max_length=50)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_profiles',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AnalysisSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ticker', models.CharField(max_length=10)),
|
||||
('analysis_date', models.DateField()),
|
||||
('analysts_selected', models.JSONField()),
|
||||
('research_depth', models.IntegerField()),
|
||||
('shallow_thinker', models.CharField(max_length=50)),
|
||||
('deep_thinker', models.CharField(max_length=50)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('final_report', models.TextField(blank=True, null=True)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analysis_sessions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'analysis_sessions',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from cryptography.fernet import Fernet
|
||||
from django.conf import settings
|
||||
import base64
|
||||
import os
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""확장된 사용자 모델"""
|
||||
email = models.EmailField(unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""사용자 프로필 및 API 키 관리"""
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
|
||||
# 암호화된 OpenAI API 키 저장
|
||||
encrypted_openai_api_key = models.TextField(blank=True, null=True)
|
||||
|
||||
# 기본 설정
|
||||
default_ticker = models.CharField(max_length=10, default='SPY')
|
||||
preferred_research_depth = models.IntegerField(default=3, choices=[
|
||||
(1, 'Shallow'),
|
||||
(3, 'Medium'),
|
||||
(5, 'Deep')
|
||||
])
|
||||
preferred_shallow_thinker = models.CharField(max_length=50, default='gpt-4o-mini')
|
||||
preferred_deep_thinker = models.CharField(max_length=50, default='gpt-4o')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_profiles'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}'s Profile"
|
||||
|
||||
@staticmethod
|
||||
def _get_cipher_key():
|
||||
"""암호화/복호화용 키 생성"""
|
||||
# Django SECRET_KEY를 기반으로 암호화 키 생성
|
||||
key = base64.urlsafe_b64encode(settings.SECRET_KEY[:32].encode())
|
||||
return Fernet(key)
|
||||
|
||||
def set_openai_api_key(self, api_key):
|
||||
"""OpenAI API 키를 암호화하여 저장"""
|
||||
if api_key:
|
||||
cipher = self._get_cipher_key()
|
||||
encrypted_key = cipher.encrypt(api_key.encode())
|
||||
self.encrypted_openai_api_key = base64.urlsafe_b64encode(encrypted_key).decode()
|
||||
else:
|
||||
self.encrypted_openai_api_key = None
|
||||
|
||||
def get_openai_api_key(self):
|
||||
"""저장된 OpenAI API 키를 복호화하여 반환"""
|
||||
if not self.encrypted_openai_api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
cipher = self._get_cipher_key()
|
||||
encrypted_key = base64.urlsafe_b64decode(self.encrypted_openai_api_key.encode())
|
||||
decrypted_key = cipher.decrypt(encrypted_key)
|
||||
return decrypted_key.decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_openai_api_key(self):
|
||||
"""사용자가 OpenAI API 키를 설정했는지 확인"""
|
||||
return bool(self.encrypted_openai_api_key)
|
||||
|
||||
def get_effective_openai_api_key(self):
|
||||
"""
|
||||
사용자 API 키가 있으면 사용자 키를, 없으면 개발자 기본 키를 반환
|
||||
"""
|
||||
user_key = self.get_openai_api_key()
|
||||
if user_key:
|
||||
return user_key
|
||||
|
||||
# 개발자가 등록한 기본 키 사용
|
||||
return getattr(settings, 'OPENAI_API_KEY', '')
|
||||
|
||||
|
||||
class AnalysisSession(models.Model):
|
||||
"""분석 세션 관리"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='analysis_sessions')
|
||||
|
||||
# 분석 파라미터
|
||||
ticker = models.CharField(max_length=10)
|
||||
analysis_date = models.DateField()
|
||||
analysts_selected = models.JSONField() # 선택된 분석가들
|
||||
research_depth = models.IntegerField()
|
||||
shallow_thinker = models.CharField(max_length=50)
|
||||
deep_thinker = models.CharField(max_length=50)
|
||||
|
||||
# 세션 상태
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='pending')
|
||||
|
||||
# 결과 저장
|
||||
final_report = models.TextField(blank=True, null=True)
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
|
||||
# 시간 추적
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(blank=True, null=True)
|
||||
completed_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'analysis_sessions'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.ticker} ({self.status})"
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
from rest_framework import serializers
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from .models import User, UserProfile, AnalysisSession
|
||||
|
||||
|
||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
"""사용자 회원가입 시리얼라이저"""
|
||||
password = serializers.CharField(write_only=True, validators=[validate_password])
|
||||
password_confirm = serializers.CharField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('email', 'username', 'password', 'password_confirm', 'first_name', 'last_name')
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password_confirm']:
|
||||
raise serializers.ValidationError("비밀번호가 일치하지 않습니다.")
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('password_confirm')
|
||||
user = User.objects.create_user(**validated_data)
|
||||
|
||||
# 사용자 프로필 자동 생성
|
||||
UserProfile.objects.create(user=user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class UserLoginSerializer(serializers.Serializer):
|
||||
"""사용자 로그인 시리얼라이저"""
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(write_only=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
email = attrs.get('email')
|
||||
password = attrs.get('password')
|
||||
|
||||
if email and password:
|
||||
user = authenticate(username=email, password=password)
|
||||
if not user:
|
||||
raise serializers.ValidationError('올바르지 않은 이메일 또는 비밀번호입니다.')
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError('비활성화된 계정입니다.')
|
||||
attrs['user'] = user
|
||||
else:
|
||||
raise serializers.ValidationError('이메일과 비밀번호를 모두 입력해주세요.')
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""사용자 프로필 시리얼라이저"""
|
||||
has_openai_api_key = serializers.SerializerMethodField()
|
||||
openai_api_key = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = (
|
||||
'default_ticker',
|
||||
'preferred_research_depth',
|
||||
'preferred_shallow_thinker',
|
||||
'preferred_deep_thinker',
|
||||
'has_openai_api_key',
|
||||
'openai_api_key',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
)
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
def get_has_openai_api_key(self, obj):
|
||||
return obj.has_openai_api_key()
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
openai_api_key = validated_data.pop('openai_api_key', None)
|
||||
|
||||
# OpenAI API 키 업데이트
|
||||
if openai_api_key is not None:
|
||||
instance.set_openai_api_key(openai_api_key)
|
||||
|
||||
# 다른 필드 업데이트
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""사용자 정보 시리얼라이저"""
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'email', 'username', 'first_name', 'last_name', 'profile', 'date_joined')
|
||||
read_only_fields = ('id', 'email', 'date_joined')
|
||||
|
||||
|
||||
class AnalysisSessionSerializer(serializers.ModelSerializer):
|
||||
"""분석 세션 시리얼라이저"""
|
||||
user_email = serializers.CharField(source='user.email', read_only=True)
|
||||
duration = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AnalysisSession
|
||||
fields = (
|
||||
'id', 'user_email', 'ticker', 'analysis_date',
|
||||
'analysts_selected', 'research_depth', 'shallow_thinker', 'deep_thinker',
|
||||
'status', 'final_report', 'error_message',
|
||||
'created_at', 'started_at', 'completed_at', 'duration'
|
||||
)
|
||||
read_only_fields = ('id', 'user_email', 'created_at', 'duration')
|
||||
|
||||
def get_duration(self, obj):
|
||||
if obj.started_at and obj.completed_at:
|
||||
duration = obj.completed_at - obj.started_at
|
||||
return int(duration.total_seconds())
|
||||
return None
|
||||
|
||||
|
||||
class CreateAnalysisSessionSerializer(serializers.ModelSerializer):
|
||||
"""분석 세션 생성 시리얼라이저"""
|
||||
class Meta:
|
||||
model = AnalysisSession
|
||||
fields = (
|
||||
'ticker', 'analysis_date',
|
||||
'analysts_selected', 'research_depth',
|
||||
'shallow_thinker', 'deep_thinker'
|
||||
)
|
||||
|
||||
def validate_analysts_selected(self, value):
|
||||
"""선택된 분석가들 검증"""
|
||||
if not isinstance(value, list) or len(value) == 0:
|
||||
raise serializers.ValidationError("최소 하나의 분석가를 선택해야 합니다.")
|
||||
|
||||
valid_analysts = ['market', 'social', 'news', 'fundamentals']
|
||||
for analyst in value:
|
||||
if analyst not in valid_analysts:
|
||||
raise serializers.ValidationError(f"올바르지 않은 분석가: {analyst}")
|
||||
|
||||
return value
|
||||
|
||||
def validate_research_depth(self, value):
|
||||
"""연구 깊이 검증"""
|
||||
if value not in [1, 3, 5]:
|
||||
raise serializers.ValidationError("연구 깊이는 1, 3, 5 중 하나여야 합니다.")
|
||||
return value
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import User, UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
"""사용자 생성 시 자동으로 프로필 생성"""
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
"""사용자 저장 시 프로필도 함께 저장"""
|
||||
if hasattr(instance, 'profile'):
|
||||
instance.profile.save()
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from . import views
|
||||
|
||||
app_name = 'authentication'
|
||||
|
||||
urlpatterns = [
|
||||
# 인증 관련
|
||||
path('register/', views.UserRegistrationView.as_view(), name='register'),
|
||||
path('login/', views.UserLoginView.as_view(), name='login'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
|
||||
# 사용자 정보
|
||||
path('user/', views.UserInfoView.as_view(), name='user_info'),
|
||||
path('profile/', views.UserProfileView.as_view(), name='user_profile'),
|
||||
|
||||
# OpenAI API 키 관리
|
||||
path('check-api-key/', views.check_openai_api_key, name='check_api_key'),
|
||||
path('remove-api-key/', views.remove_openai_api_key, name='remove_api_key'),
|
||||
|
||||
# 분석 세션 관리
|
||||
path('sessions/', views.AnalysisSessionListView.as_view(), name='analysis_sessions'),
|
||||
path('sessions/<int:pk>/', views.AnalysisSessionDetailView.as_view(), name='analysis_session_detail'),
|
||||
]
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
from rest_framework import status, generics, permissions
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from django.contrib.auth import authenticate
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .models import User, UserProfile, AnalysisSession
|
||||
from .serializers import (
|
||||
UserRegistrationSerializer,
|
||||
UserLoginSerializer,
|
||||
UserSerializer,
|
||||
UserProfileSerializer,
|
||||
AnalysisSessionSerializer,
|
||||
CreateAnalysisSessionSerializer
|
||||
)
|
||||
|
||||
|
||||
class UserRegistrationView(APIView):
|
||||
"""사용자 회원가입"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = UserRegistrationSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
|
||||
# JWT 토큰 생성
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'message': '회원가입이 완료되었습니다.',
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserLoginView(APIView):
|
||||
"""사용자 로그인"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = UserLoginSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.validated_data['user']
|
||||
|
||||
# JWT 토큰 생성
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'message': '로그인이 완료되었습니다.',
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserProfileView(APIView):
|
||||
"""사용자 프로필 조회 및 수정"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""프로필 조회"""
|
||||
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||
serializer = UserProfileSerializer(profile)
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(self, request):
|
||||
"""프로필 수정"""
|
||||
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||
serializer = UserProfileSerializer(profile, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({
|
||||
'message': '프로필이 업데이트되었습니다.',
|
||||
'profile': serializer.data
|
||||
})
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserInfoView(APIView):
|
||||
"""사용자 정보 조회"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
serializer = UserSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class AnalysisSessionListView(generics.ListCreateAPIView):
|
||||
"""분석 세션 목록 조회 및 생성"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return AnalysisSession.objects.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method == 'POST':
|
||||
return CreateAnalysisSessionSerializer
|
||||
return AnalysisSessionSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
class AnalysisSessionDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""분석 세션 상세 조회, 수정, 삭제"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = AnalysisSessionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return AnalysisSession.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def check_openai_api_key(request):
|
||||
"""OpenAI API 키 유효성 검사"""
|
||||
try:
|
||||
profile = request.user.profile
|
||||
api_key = profile.get_effective_openai_api_key()
|
||||
|
||||
if not api_key:
|
||||
return Response({
|
||||
'valid': False,
|
||||
'message': 'OpenAI API 키가 설정되지 않았습니다.'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 실제 OpenAI API 호출로 키 검증 (간단한 모델 목록 요청)
|
||||
import openai
|
||||
openai.api_key = api_key
|
||||
|
||||
try:
|
||||
# 간단한 API 호출로 키 유효성 확인
|
||||
response = openai.models.list()
|
||||
return Response({
|
||||
'valid': True,
|
||||
'message': 'OpenAI API 키가 유효합니다.',
|
||||
'using_user_key': profile.has_openai_api_key()
|
||||
})
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'valid': False,
|
||||
'message': f'OpenAI API 키가 유효하지 않습니다: {str(e)}'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def remove_openai_api_key(request):
|
||||
"""사용자의 OpenAI API 키 제거"""
|
||||
try:
|
||||
profile = request.user.profile
|
||||
profile.set_openai_api_key(None)
|
||||
profile.save()
|
||||
|
||||
return Response({
|
||||
'message': 'OpenAI API 키가 제거되었습니다. 이제 기본 키를 사용합니다.'
|
||||
})
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TradingApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.trading_api'
|
||||
verbose_name = '거래 분석 API'
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from django.conf import settings
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
# CLI 모듈 import (경로 조정 필요)
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.join(settings.BASE_DIR.parent.parent))
|
||||
|
||||
from cli.models import AnalystType
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
from apps.authentication.models import AnalysisSession, UserProfile
|
||||
|
||||
|
||||
class TradingAnalysisService:
|
||||
"""거래 분석 서비스"""
|
||||
|
||||
def __init__(self, user, analysis_session: AnalysisSession):
|
||||
self.user = user
|
||||
self.session = analysis_session
|
||||
self.channel_layer = get_channel_layer()
|
||||
self.user_channel_group = f"user_{user.id}"
|
||||
|
||||
async def run_analysis(self):
|
||||
"""분석 실행"""
|
||||
try:
|
||||
# 세션 상태 업데이트
|
||||
self.session.status = 'running'
|
||||
self.session.started_at = datetime.datetime.now()
|
||||
self.session.save()
|
||||
|
||||
# WebSocket으로 시작 알림
|
||||
await self._send_websocket_message({
|
||||
'type': 'analysis_started',
|
||||
'session_id': self.session.id,
|
||||
'message': '분석을 시작합니다...'
|
||||
})
|
||||
|
||||
# 사용자 프로필에서 OpenAI API 키 가져오기
|
||||
profile = self.user.profile
|
||||
api_key = profile.get_effective_openai_api_key()
|
||||
|
||||
if not api_key:
|
||||
raise Exception("OpenAI API 키가 설정되지 않았습니다.")
|
||||
|
||||
# CLI 설정 준비
|
||||
config = self._prepare_analysis_config(api_key)
|
||||
|
||||
# 분석 실행
|
||||
result = await self._execute_trading_analysis(config)
|
||||
|
||||
# 결과 저장
|
||||
self.session.final_report = result
|
||||
self.session.status = 'completed'
|
||||
self.session.completed_at = datetime.datetime.now()
|
||||
self.session.save()
|
||||
|
||||
# WebSocket으로 완료 알림
|
||||
await self._send_websocket_message({
|
||||
'type': 'analysis_completed',
|
||||
'session_id': self.session.id,
|
||||
'message': '분석이 완료되었습니다.',
|
||||
'result': result
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 에러 처리
|
||||
self.session.status = 'failed'
|
||||
self.session.error_message = str(e)
|
||||
self.session.completed_at = datetime.datetime.now()
|
||||
self.session.save()
|
||||
|
||||
# WebSocket으로 에러 알림
|
||||
await self._send_websocket_message({
|
||||
'type': 'analysis_failed',
|
||||
'session_id': self.session.id,
|
||||
'message': f'분석 중 오류가 발생했습니다: {str(e)}'
|
||||
})
|
||||
|
||||
raise e
|
||||
|
||||
def _prepare_analysis_config(self, api_key: str) -> Dict:
|
||||
"""분석 설정 준비"""
|
||||
# AnalysisSession의 설정을 CLI 형식으로 변환
|
||||
analysts = []
|
||||
for analyst_str in self.session.analysts_selected:
|
||||
analysts.append(AnalystType(analyst_str))
|
||||
|
||||
config = {
|
||||
'ticker': self.session.ticker,
|
||||
'analysis_date': self.session.analysis_date.strftime('%Y-%m-%d'),
|
||||
'analysts': analysts,
|
||||
'research_depth': self.session.research_depth,
|
||||
'shallow_thinker': self.session.shallow_thinker,
|
||||
'deep_thinker': self.session.deep_thinker,
|
||||
'openai_api_key': api_key
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
async def _execute_trading_analysis(self, config: Dict) -> str:
|
||||
"""실제 거래 분석 실행"""
|
||||
try:
|
||||
# 기본 설정 업데이트
|
||||
analysis_config = DEFAULT_CONFIG.copy()
|
||||
analysis_config.update({
|
||||
'openai_api_key': config['openai_api_key'],
|
||||
'shallow_thinking_model': config['shallow_thinker'],
|
||||
'deep_thinking_model': config['deep_thinker'],
|
||||
})
|
||||
|
||||
# TradingAgentsGraph 초기화
|
||||
trading_graph = TradingAgentsGraph(analysis_config)
|
||||
|
||||
# 분석 입력 데이터 준비
|
||||
input_data = {
|
||||
'ticker': config['ticker'],
|
||||
'date': config['analysis_date'],
|
||||
'selected_analysts': [analyst.value for analyst in config['analysts']],
|
||||
'research_depth': config['research_depth'],
|
||||
}
|
||||
|
||||
# 진행 상황 콜백 함수
|
||||
async def progress_callback(message_type: str, content: str, agent: str = None):
|
||||
await self._send_websocket_message({
|
||||
'type': 'analysis_progress',
|
||||
'session_id': self.session.id,
|
||||
'message_type': message_type,
|
||||
'content': content,
|
||||
'agent': agent
|
||||
})
|
||||
|
||||
# 분석 실행 (실제 CLI 로직 호출)
|
||||
# 여기서는 간단화된 버전으로 구현
|
||||
# 실제로는 trading_graph.invoke(input_data) 형태로 호출
|
||||
|
||||
# 분석 진행 상황 시뮬레이션
|
||||
analysis_steps = [
|
||||
("Market Analyst", "시장 데이터 분석 중..."),
|
||||
("Social Analyst", "소셜 센티멘트 분석 중..."),
|
||||
("News Analyst", "뉴스 분석 중..."),
|
||||
("Fundamentals Analyst", "기본 분석 중..."),
|
||||
("Research Manager", "연구 결과 종합 중..."),
|
||||
("Trader", "거래 전략 수립 중..."),
|
||||
("Portfolio Manager", "포트폴리오 최적화 중...")
|
||||
]
|
||||
|
||||
final_report_parts = []
|
||||
|
||||
for agent, message in analysis_steps:
|
||||
await progress_callback("agent_update", message, agent)
|
||||
|
||||
# 실제 분석 로직 호출 (여기서는 시뮬레이션)
|
||||
await asyncio.sleep(2) # 실제 분석 시간 시뮬레이션
|
||||
|
||||
# 각 단계별 결과 생성 (실제로는 trading_graph의 결과)
|
||||
step_result = f"## {agent} 분석 결과\n\n{config['ticker']} 종목에 대한 {agent.lower()} 분석을 완료했습니다.\n"
|
||||
final_report_parts.append(step_result)
|
||||
|
||||
# 최종 보고서 생성
|
||||
final_report = "\n\n".join(final_report_parts)
|
||||
|
||||
return final_report
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"분석 실행 중 오류 발생: {str(e)}")
|
||||
|
||||
async def _send_websocket_message(self, message: Dict):
|
||||
"""WebSocket으로 메시지 전송"""
|
||||
try:
|
||||
await self.channel_layer.group_send(
|
||||
self.user_channel_group,
|
||||
{
|
||||
'type': 'trading_analysis_message',
|
||||
'message': message
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"WebSocket 메시지 전송 실패: {e}")
|
||||
|
||||
|
||||
class TradingAnalysisManager:
|
||||
"""거래 분석 관리자"""
|
||||
|
||||
@staticmethod
|
||||
def create_analysis_session(user, analysis_data: Dict) -> AnalysisSession:
|
||||
"""분석 세션 생성"""
|
||||
session = AnalysisSession.objects.create(
|
||||
user=user,
|
||||
ticker=analysis_data['ticker'],
|
||||
analysis_date=analysis_data['analysis_date'],
|
||||
analysts_selected=analysis_data['analysts_selected'],
|
||||
research_depth=analysis_data['research_depth'],
|
||||
shallow_thinker=analysis_data['shallow_thinker'],
|
||||
deep_thinker=analysis_data['deep_thinker'],
|
||||
)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
async def start_analysis(user, session_id: int):
|
||||
"""분석 시작"""
|
||||
try:
|
||||
session = AnalysisSession.objects.get(id=session_id, user=user)
|
||||
service = TradingAnalysisService(user, session)
|
||||
result = await service.run_analysis()
|
||||
return result
|
||||
except AnalysisSession.DoesNotExist:
|
||||
raise Exception("분석 세션을 찾을 수 없습니다.")
|
||||
|
||||
@staticmethod
|
||||
def get_user_analysis_sessions(user) -> List[AnalysisSession]:
|
||||
"""사용자의 분석 세션 목록 조회"""
|
||||
return AnalysisSession.objects.filter(user=user).order_by('-created_at')
|
||||
|
||||
@staticmethod
|
||||
def cancel_analysis(user, session_id: int):
|
||||
"""분석 취소"""
|
||||
try:
|
||||
session = AnalysisSession.objects.get(id=session_id, user=user)
|
||||
if session.status == 'running':
|
||||
session.status = 'cancelled'
|
||||
session.completed_at = datetime.datetime.now()
|
||||
session.save()
|
||||
return True
|
||||
return False
|
||||
except AnalysisSession.DoesNotExist:
|
||||
raise Exception("분석 세션을 찾을 수 없습니다.")
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'trading_api'
|
||||
|
||||
urlpatterns = [
|
||||
# 분석 설정 및 옵션
|
||||
path('config/', views.AnalysisConfigView.as_view(), name='analysis_config'),
|
||||
path('options/', views.get_analysis_options, name='analysis_options'),
|
||||
|
||||
# 분석 실행
|
||||
path('start/', views.StartAnalysisView.as_view(), name='start_analysis'),
|
||||
path('status/<int:session_id>/', views.AnalysisStatusView.as_view(), name='analysis_status'),
|
||||
path('cancel/<int:session_id>/', views.CancelAnalysisView.as_view(), name='cancel_analysis'),
|
||||
|
||||
# 분석 기록 및 결과
|
||||
path('history/', views.AnalysisHistoryView.as_view(), name='analysis_history'),
|
||||
path('report/<int:session_id>/', views.AnalysisReportView.as_view(), name='analysis_report'),
|
||||
path('running/', views.get_running_analyses, name='running_analyses'),
|
||||
]
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
from rest_framework import status, permissions
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from asgiref.sync import sync_to_async
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from apps.authentication.models import AnalysisSession
|
||||
from apps.authentication.serializers import AnalysisSessionSerializer, CreateAnalysisSessionSerializer
|
||||
from .services import TradingAnalysisManager, TradingAnalysisService
|
||||
|
||||
|
||||
class AnalysisConfigView(APIView):
|
||||
"""분석 설정 정보 조회"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""분석 설정 옵션들 반환"""
|
||||
config = {
|
||||
'analysts': [
|
||||
{'value': 'market', 'label': 'Market Analyst', 'description': '시장 데이터 분석'},
|
||||
{'value': 'social', 'label': 'Social Analyst', 'description': '소셜 센티멘트 분석'},
|
||||
{'value': 'news', 'label': 'News Analyst', 'description': '뉴스 분석'},
|
||||
{'value': 'fundamentals', 'label': 'Fundamentals Analyst', 'description': '기본 분석'},
|
||||
],
|
||||
'research_depths': [
|
||||
{'value': 1, 'label': 'Shallow', 'description': '빠른 분석, 적은 토론 라운드'},
|
||||
{'value': 3, 'label': 'Medium', 'description': '중간 정도 분석, 보통 토론 라운드'},
|
||||
{'value': 5, 'label': 'Deep', 'description': '깊은 분석, 많은 토론 라운드'},
|
||||
],
|
||||
'shallow_thinkers': [
|
||||
{'value': 'gpt-4o-mini', 'label': 'GPT-4o-mini', 'description': '빠르고 효율적'},
|
||||
{'value': 'gpt-4.1-nano', 'label': 'GPT-4.1-nano', 'description': '초경량 모델'},
|
||||
{'value': 'gpt-4.1-mini', 'label': 'GPT-4.1-mini', 'description': '컴팩트 모델'},
|
||||
{'value': 'gpt-4o', 'label': 'GPT-4o', 'description': '표준 모델'},
|
||||
],
|
||||
'deep_thinkers': [
|
||||
{'value': 'gpt-4.1-nano', 'label': 'GPT-4.1-nano', 'description': '초경량 모델'},
|
||||
{'value': 'gpt-4.1-mini', 'label': 'GPT-4.1-mini', 'description': '컴팩트 모델'},
|
||||
{'value': 'gpt-4o', 'label': 'GPT-4o', 'description': '표준 모델'},
|
||||
{'value': 'o4-mini', 'label': 'o4-mini', 'description': '추론 특화 모델 (컴팩트)'},
|
||||
{'value': 'o3-mini', 'label': 'o3-mini', 'description': '고급 추론 모델 (경량)'},
|
||||
{'value': 'o3', 'label': 'o3', 'description': '완전한 고급 추론 모델'},
|
||||
{'value': 'o1', 'label': 'o1', 'description': '최고급 추론 및 문제 해결 모델'},
|
||||
]
|
||||
}
|
||||
|
||||
return Response(config)
|
||||
|
||||
|
||||
class StartAnalysisView(APIView):
|
||||
"""분석 시작"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
"""새로운 분석 시작"""
|
||||
serializer = CreateAnalysisSessionSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
# 분석 세션 생성
|
||||
session = serializer.save(user=request.user)
|
||||
|
||||
# 백그라운드에서 분석 실행
|
||||
# 실제 환경에서는 Celery나 다른 task queue를 사용하는 것이 좋습니다
|
||||
asyncio.create_task(self._start_analysis_async(request.user, session.id))
|
||||
|
||||
return Response({
|
||||
'message': '분석이 시작되었습니다.',
|
||||
'session_id': session.id,
|
||||
'session': AnalysisSessionSerializer(session).data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
async def _start_analysis_async(self, user, session_id):
|
||||
"""비동기 분석 실행"""
|
||||
try:
|
||||
await TradingAnalysisManager.start_analysis(user, session_id)
|
||||
except Exception as e:
|
||||
print(f"분석 실행 중 오류: {e}")
|
||||
|
||||
|
||||
class AnalysisStatusView(APIView):
|
||||
"""분석 상태 조회"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request, session_id):
|
||||
"""특정 분석 세션의 상태 조회"""
|
||||
session = get_object_or_404(
|
||||
AnalysisSession,
|
||||
id=session_id,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
serializer = AnalysisSessionSerializer(session)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class CancelAnalysisView(APIView):
|
||||
"""분석 취소"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request, session_id):
|
||||
"""분석 취소"""
|
||||
try:
|
||||
success = TradingAnalysisManager.cancel_analysis(request.user, session_id)
|
||||
|
||||
if success:
|
||||
return Response({
|
||||
'message': '분석이 취소되었습니다.',
|
||||
'session_id': session_id
|
||||
})
|
||||
else:
|
||||
return Response({
|
||||
'message': '취소할 수 없는 상태입니다.',
|
||||
'session_id': session_id
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class AnalysisHistoryView(APIView):
|
||||
"""분석 기록 조회"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""사용자의 분석 기록 조회"""
|
||||
sessions = TradingAnalysisManager.get_user_analysis_sessions(request.user)
|
||||
serializer = AnalysisSessionSerializer(sessions, many=True)
|
||||
|
||||
return Response({
|
||||
'count': len(sessions),
|
||||
'results': serializer.data
|
||||
})
|
||||
|
||||
|
||||
class AnalysisReportView(APIView):
|
||||
"""분석 보고서 조회"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request, session_id):
|
||||
"""특정 분석 세션의 보고서 조회"""
|
||||
session = get_object_or_404(
|
||||
AnalysisSession,
|
||||
id=session_id,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
if session.status != 'completed':
|
||||
return Response({
|
||||
'message': '분석이 완료되지 않았습니다.',
|
||||
'status': session.status
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response({
|
||||
'session_id': session.id,
|
||||
'ticker': session.ticker,
|
||||
'analysis_date': session.analysis_date,
|
||||
'final_report': session.final_report,
|
||||
'completed_at': session.completed_at,
|
||||
'duration': (session.completed_at - session.started_at).total_seconds() if session.started_at and session.completed_at else None
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def get_analysis_options(request):
|
||||
"""분석 옵션 조회 (간단한 버전)"""
|
||||
options = {
|
||||
'default_values': {
|
||||
'ticker': 'SPY',
|
||||
'analysis_date': datetime.now().strftime('%Y-%m-%d'),
|
||||
'analysts_selected': ['market', 'social', 'news', 'fundamentals'],
|
||||
'research_depth': 3,
|
||||
'shallow_thinker': 'gpt-4o-mini',
|
||||
'deep_thinker': 'gpt-4o'
|
||||
}
|
||||
}
|
||||
|
||||
# 사용자 프로필의 기본값이 있다면 사용
|
||||
if hasattr(request.user, 'profile'):
|
||||
profile = request.user.profile
|
||||
options['user_preferences'] = {
|
||||
'default_ticker': profile.default_ticker,
|
||||
'preferred_research_depth': profile.preferred_research_depth,
|
||||
'preferred_shallow_thinker': profile.preferred_shallow_thinker,
|
||||
'preferred_deep_thinker': profile.preferred_deep_thinker,
|
||||
}
|
||||
|
||||
return Response(options)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def get_running_analyses(request):
|
||||
"""실행 중인 분석 조회"""
|
||||
running_sessions = AnalysisSession.objects.filter(
|
||||
user=request.user,
|
||||
status='running'
|
||||
)
|
||||
|
||||
serializer = AnalysisSessionSerializer(running_sessions, many=True)
|
||||
|
||||
return Response({
|
||||
'count': len(running_sessions),
|
||||
'results': serializer.data
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
# WebSocket app
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WebsocketConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.websocket'
|
||||
verbose_name = 'WebSocket 통신'
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework_simplejwt.tokens import UntypedToken
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
import jwt
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TradingAnalysisConsumer(AsyncWebsocketConsumer):
|
||||
"""거래 분석 실시간 업데이트 WebSocket Consumer"""
|
||||
|
||||
async def connect(self):
|
||||
"""WebSocket 연결"""
|
||||
# JWT 토큰 인증
|
||||
user = await self.get_user_from_token()
|
||||
|
||||
if user and user.is_authenticated:
|
||||
self.user = user
|
||||
self.user_group_name = f"user_{user.id}"
|
||||
|
||||
# 사용자 그룹에 추가
|
||||
await self.channel_layer.group_add(
|
||||
self.user_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
await self.accept()
|
||||
|
||||
# 연결 성공 메시지 전송
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'message': 'WebSocket 연결이 성공적으로 설정되었습니다.',
|
||||
'user_id': user.id
|
||||
}))
|
||||
else:
|
||||
await self.close()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""WebSocket 연결 해제"""
|
||||
if hasattr(self, 'user_group_name'):
|
||||
await self.channel_layer.group_discard(
|
||||
self.user_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""클라이언트로부터 메시지 수신"""
|
||||
try:
|
||||
text_data_json = json.loads(text_data)
|
||||
message_type = text_data_json.get('type', '')
|
||||
|
||||
if message_type == 'ping':
|
||||
# 연결 상태 확인용 ping
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'pong',
|
||||
'timestamp': text_data_json.get('timestamp')
|
||||
}))
|
||||
elif message_type == 'subscribe_analysis':
|
||||
# 특정 분석 세션 구독
|
||||
session_id = text_data_json.get('session_id')
|
||||
if session_id:
|
||||
await self.subscribe_to_analysis(session_id)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': '잘못된 JSON 형식입니다.'
|
||||
}))
|
||||
|
||||
async def subscribe_to_analysis(self, session_id):
|
||||
"""특정 분석 세션 구독"""
|
||||
# 분석 세션이 사용자의 것인지 확인
|
||||
session_exists = await self.check_session_ownership(session_id)
|
||||
|
||||
if session_exists:
|
||||
analysis_group = f"analysis_{session_id}"
|
||||
await self.channel_layer.group_add(
|
||||
analysis_group,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'subscription_confirmed',
|
||||
'session_id': session_id,
|
||||
'message': f'분석 세션 {session_id}에 구독되었습니다.'
|
||||
}))
|
||||
else:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'subscription_failed',
|
||||
'session_id': session_id,
|
||||
'message': '해당 분석 세션에 대한 권한이 없습니다.'
|
||||
}))
|
||||
|
||||
# 분석 관련 메시지 핸들러들
|
||||
async def trading_analysis_message(self, event):
|
||||
"""분석 관련 메시지 전송"""
|
||||
message = event['message']
|
||||
await self.send(text_data=json.dumps(message))
|
||||
|
||||
async def analysis_progress(self, event):
|
||||
"""분석 진행 상황 업데이트"""
|
||||
await self.send(text_data=json.dumps(event))
|
||||
|
||||
async def analysis_started(self, event):
|
||||
"""분석 시작 알림"""
|
||||
await self.send(text_data=json.dumps(event))
|
||||
|
||||
async def analysis_completed(self, event):
|
||||
"""분석 완료 알림"""
|
||||
await self.send(text_data=json.dumps(event))
|
||||
|
||||
async def analysis_failed(self, event):
|
||||
"""분석 실패 알림"""
|
||||
await self.send(text_data=json.dumps(event))
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user_from_token(self):
|
||||
"""JWT 토큰에서 사용자 정보 추출"""
|
||||
try:
|
||||
# URL에서 토큰 추출 (query parameter 또는 header)
|
||||
token = None
|
||||
|
||||
# Query parameter에서 토큰 추출
|
||||
query_string = self.scope.get('query_string', b'').decode()
|
||||
if 'token=' in query_string:
|
||||
token = query_string.split('token=')[1].split('&')[0]
|
||||
|
||||
# 헤더에서 토큰 추출
|
||||
if not token:
|
||||
headers = dict(self.scope['headers'])
|
||||
auth_header = headers.get(b'authorization', b'').decode()
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
|
||||
if not token:
|
||||
return AnonymousUser()
|
||||
|
||||
# JWT 토큰 검증
|
||||
try:
|
||||
UntypedToken(token) # 토큰 유효성 검사
|
||||
|
||||
# 토큰에서 사용자 ID 추출
|
||||
decoded_token = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=['HS256']
|
||||
)
|
||||
user_id = decoded_token.get('user_id')
|
||||
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id)
|
||||
return user
|
||||
|
||||
except (InvalidToken, TokenError, jwt.ExpiredSignatureError):
|
||||
return AnonymousUser()
|
||||
except User.DoesNotExist:
|
||||
return AnonymousUser()
|
||||
|
||||
except Exception as e:
|
||||
print(f"WebSocket 인증 중 오류: {e}")
|
||||
return AnonymousUser()
|
||||
|
||||
return AnonymousUser()
|
||||
|
||||
@database_sync_to_async
|
||||
def check_session_ownership(self, session_id):
|
||||
"""분석 세션 소유권 확인"""
|
||||
try:
|
||||
from apps.authentication.models import AnalysisSession
|
||||
session = AnalysisSession.objects.get(id=session_id, user=self.user)
|
||||
return True
|
||||
except AnalysisSession.DoesNotExist:
|
||||
return False
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/trading-analysis/$', consumers.TradingAnalysisConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
app_name = 'websocket'
|
||||
|
||||
# WebSocket은 ASGI routing을 통해 처리되므로 HTTP URL은 없음
|
||||
urlpatterns = [
|
||||
# WebSocket 관련 HTTP 엔드포인트가 필요한 경우 여기에 추가
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tradingagents_web.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1 @@
|
|||
# This file makes Python treat the directory as a package
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
ASGI config for tradingagents_web project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
import apps.websocket.routing
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tradingagents_web.settings')
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
apps.websocket.routing.websocket_urlpatterns
|
||||
)
|
||||
),
|
||||
})
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Django settings for tradingagents_web project.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from decouple import config
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = config('SECRET_KEY', default='django-insecure-your-secret-key-here')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = config('DEBUG', default=True, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1').split(',')
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
'channels',
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
'apps.authentication',
|
||||
'apps.trading_api',
|
||||
'apps.websocket',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'tradingagents_web.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'tradingagents_web.wsgi.application'
|
||||
ASGI_APPLICATION = 'tradingagents_web.asgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': config('DB_NAME', default='tradingagents_web'),
|
||||
'USER': config('DB_USER', default='root'),
|
||||
'PASSWORD': config('DB_PASSWORD', default='password'),
|
||||
'HOST': config('DB_HOST', default='localhost'),
|
||||
'PORT': config('DB_PORT', default='3306'),
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'ko-kr'
|
||||
TIME_ZONE = 'Asia/Seoul'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = 'authentication.User'
|
||||
|
||||
# REST Framework configuration
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 20,
|
||||
}
|
||||
|
||||
# JWT configuration
|
||||
from datetime import timedelta
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
}
|
||||
|
||||
# CORS configuration for React frontend
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # React development server
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# Channels configuration for WebSocket
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [(config('REDIS_HOST', default='127.0.0.1'), config('REDIS_PORT', default=6379, cast=int))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY = config('OPENAI_API_KEY', default='') # 개발자가 등록한 기본 API 키
|
||||
|
||||
# Trading Agents Configuration
|
||||
TRADING_AGENTS_CONFIG = {
|
||||
'DEFAULT_TICKER': 'SPY',
|
||||
'MAX_CONCURRENT_ANALYSES': 5,
|
||||
'ANALYSIS_TIMEOUT': 300, # 5 minutes
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""
|
||||
tradingagents_web URL Configuration
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/auth/', include('apps.authentication.urls')),
|
||||
path('api/trading/', include('apps.trading_api.urls')),
|
||||
path('ws/', include('apps.websocket.urls')),
|
||||
]
|
||||
|
||||
# Serve media files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for tradingagents_web project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tradingagents_web.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "tradingagents-web-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-router-dom": "^6.4.0",
|
||||
"axios": "^1.5.0",
|
||||
"antd": "^5.10.0",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"styled-components": "^6.0.8",
|
||||
"dayjs": "^1.11.9",
|
||||
"react-markdown": "^8.0.7",
|
||||
"websocket": "^1.0.34",
|
||||
"recharts": "^2.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="TradingAgents - Multi-Agents LLM Financial Trading Framework"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>TradingAgents - AI 거래 분석 플랫폼</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript를 활성화해야 이 앱을 실행할 수 있습니다.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import koKR from 'antd/locale/ko_KR';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import GlobalStyle from './styles/GlobalStyle';
|
||||
import theme from './styles/theme';
|
||||
|
||||
// Components
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Login from './pages/Login/Login';
|
||||
import Register from './pages/Register/Register';
|
||||
import Dashboard from './pages/Dashboard/Dashboard';
|
||||
import Analysis from './pages/Analysis/Analysis';
|
||||
import History from './pages/History/History';
|
||||
import Profile from './pages/Profile/Profile';
|
||||
import Loading from './components/Loading/Loading';
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return user ? children : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
// Public Route Component (redirect to dashboard if already logged in)
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return !user ? children : <Navigate to="/dashboard" />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={koKR}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyle />
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<WebSocketProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</WebSocketProvider>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Layout as AntLayout, Menu, Avatar, Dropdown, Button, Badge, Typography } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
LineChartOutlined,
|
||||
HistoryOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useWebSocket } from '../../contexts/WebSocketContext';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Header, Sider, Content } = AntLayout;
|
||||
const { Title } = Typography;
|
||||
|
||||
const StyledLayout = styled(AntLayout)`
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled(Header)`
|
||||
background: ${props => props.theme.colors.background};
|
||||
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
|
||||
padding: 0 ${props => props.theme.spacing.lg};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: ${props => props.theme.zIndex.sticky};
|
||||
`;
|
||||
|
||||
const StyledSider = styled(Sider)`
|
||||
background: ${props => props.theme.colors.background};
|
||||
border-right: 1px solid ${props => props.theme.colors.borderLight};
|
||||
|
||||
.ant-layout-sider-trigger {
|
||||
background: ${props => props.theme.colors.backgroundSecondary};
|
||||
border-top: 1px solid ${props => props.theme.colors.borderLight};
|
||||
color: ${props => props.theme.colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Content)`
|
||||
background: ${props => props.theme.colors.backgroundSecondary};
|
||||
padding: ${props => props.theme.spacing.lg};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const HeaderLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${props => props.theme.spacing.md};
|
||||
`;
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${props => props.theme.spacing.md};
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${props => props.theme.spacing.sm};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.bold};
|
||||
font-size: ${props => props.theme.typography.fontSize.lg};
|
||||
color: ${props => props.theme.colors.primary};
|
||||
`;
|
||||
|
||||
const ConnectionStatus = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${props => props.theme.spacing.xs};
|
||||
font-size: ${props => props.theme.typography.fontSize.sm};
|
||||
color: ${props => props.connected ? props.theme.colors.success : props.theme.colors.error};
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${props => props.theme.spacing.sm};
|
||||
`;
|
||||
|
||||
const MainLayout = ({ children }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const { connected, reconnectAttempts, maxReconnectAttempts } = useWebSocket();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 메뉴 아이템 정의
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '대시보드',
|
||||
},
|
||||
{
|
||||
key: '/analysis',
|
||||
icon: <LineChartOutlined />,
|
||||
label: '분석 시작',
|
||||
},
|
||||
{
|
||||
key: '/history',
|
||||
icon: <HistoryOutlined />,
|
||||
label: '분석 기록',
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '프로필',
|
||||
},
|
||||
];
|
||||
|
||||
// 사용자 드롭다운 메뉴
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '프로필',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '로그아웃',
|
||||
onClick: logout,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledLayout>
|
||||
<StyledSider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={240}
|
||||
collapsedWidth={80}
|
||||
>
|
||||
<div style={{ padding: '16px', borderBottom: `1px solid #f0f0f0` }}>
|
||||
{!collapsed ? (
|
||||
<Logo>
|
||||
<LineChartOutlined style={{ fontSize: '24px' }} />
|
||||
TradingAgents
|
||||
</Logo>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<LineChartOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{ border: 'none', paddingTop: '16px' }}
|
||||
/>
|
||||
</StyledSider>
|
||||
|
||||
<AntLayout>
|
||||
<StyledHeader>
|
||||
<HeaderLeft>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleCollapsed}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Title level={4} style={{ margin: 0, color: '#262626' }}>
|
||||
{menuItems.find(item => item.key === location.pathname)?.label || 'TradingAgents'}
|
||||
</Title>
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderRight>
|
||||
{/* WebSocket 연결 상태 */}
|
||||
<ConnectionStatus connected={connected}>
|
||||
{connected ? (
|
||||
<>
|
||||
<WifiOutlined />
|
||||
<span>연결됨</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DisconnectOutlined />
|
||||
<span>
|
||||
{reconnectAttempts > 0
|
||||
? `재연결 중... (${reconnectAttempts}/${maxReconnectAttempts})`
|
||||
: '연결 끊김'
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</ConnectionStatus>
|
||||
|
||||
{/* 알림 아이콘 */}
|
||||
<Badge count={0} showZero={false}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
style={{ fontSize: '16px' }}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems }}
|
||||
placement="bottomRight"
|
||||
arrow
|
||||
>
|
||||
<UserInfo>
|
||||
<Avatar
|
||||
icon={<UserOutlined />}
|
||||
style={{
|
||||
backgroundColor: '#1890ff',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span style={{ cursor: 'pointer' }}>
|
||||
{user?.username || user?.email}
|
||||
</span>
|
||||
)}
|
||||
</UserInfo>
|
||||
</Dropdown>
|
||||
</HeaderRight>
|
||||
</StyledHeader>
|
||||
|
||||
<StyledContent>
|
||||
{children}
|
||||
</StyledContent>
|
||||
</AntLayout>
|
||||
</StyledLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: ${props => props.fullScreen ? '100vh' : '200px'};
|
||||
background-color: ${props => props.fullScreen ? props.theme.colors.background : 'transparent'};
|
||||
`;
|
||||
|
||||
const LoadingContent = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const LoadingText = styled.div`
|
||||
margin-top: ${props => props.theme.spacing.md};
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
`;
|
||||
|
||||
const CustomIcon = styled(LoadingOutlined)`
|
||||
font-size: ${props => props.size || '24px'};
|
||||
color: ${props => props.theme.colors.primary};
|
||||
`;
|
||||
|
||||
const Loading = ({
|
||||
size = 'large',
|
||||
text = '로딩 중...',
|
||||
fullScreen = false,
|
||||
spinning = true
|
||||
}) => {
|
||||
const iconSize = {
|
||||
small: '16px',
|
||||
default: '20px',
|
||||
large: '24px',
|
||||
xlarge: '32px'
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingContainer fullScreen={fullScreen}>
|
||||
<LoadingContent>
|
||||
<Spin
|
||||
indicator={<CustomIcon size={iconSize[size]} />}
|
||||
size={size}
|
||||
spinning={spinning}
|
||||
/>
|
||||
{text && <LoadingText>{text}</LoadingText>}
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import api from '../services/api';
|
||||
|
||||
// Auth Action Types
|
||||
const AUTH_ACTIONS = {
|
||||
LOGIN_START: 'LOGIN_START',
|
||||
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||
LOGIN_FAILURE: 'LOGIN_FAILURE',
|
||||
LOGOUT: 'LOGOUT',
|
||||
REGISTER_START: 'REGISTER_START',
|
||||
REGISTER_SUCCESS: 'REGISTER_SUCCESS',
|
||||
REGISTER_FAILURE: 'REGISTER_FAILURE',
|
||||
UPDATE_USER: 'UPDATE_USER',
|
||||
SET_LOADING: 'SET_LOADING',
|
||||
};
|
||||
|
||||
// Initial State
|
||||
const initialState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Auth Reducer
|
||||
const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case AUTH_ACTIONS.LOGIN_START:
|
||||
case AUTH_ACTIONS.REGISTER_START:
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGIN_SUCCESS:
|
||||
case AUTH_ACTIONS.REGISTER_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGIN_FAILURE:
|
||||
case AUTH_ACTIONS.REGISTER_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.UPDATE_USER:
|
||||
return {
|
||||
...state,
|
||||
user: { ...state.user, ...action.payload },
|
||||
};
|
||||
|
||||
case AUTH_ACTIONS.SET_LOADING:
|
||||
return {
|
||||
...state,
|
||||
loading: action.payload,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Create Context
|
||||
const AuthContext = createContext();
|
||||
|
||||
// Auth Provider Component
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// 로컬 스토리지에서 토큰 확인 및 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// API에 토큰 설정
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const response = await api.get('/api/auth/user/');
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOGIN_SUCCESS,
|
||||
payload: { user: response.data },
|
||||
});
|
||||
} catch (error) {
|
||||
// 토큰이 유효하지 않으면 제거
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
|
||||
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// 로그인 함수
|
||||
const login = async (email, password) => {
|
||||
dispatch({ type: AUTH_ACTIONS.LOGIN_START });
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/auth/login/', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const { user, tokens } = response.data;
|
||||
|
||||
// 토큰 저장
|
||||
localStorage.setItem('access_token', tokens.access);
|
||||
localStorage.setItem('refresh_token', tokens.refresh);
|
||||
|
||||
// API 헤더에 토큰 설정
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${tokens.access}`;
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOGIN_SUCCESS,
|
||||
payload: { user },
|
||||
});
|
||||
|
||||
message.success('로그인이 완료되었습니다.');
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
'로그인 중 오류가 발생했습니다.';
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.LOGIN_FAILURE,
|
||||
payload: errorMessage,
|
||||
});
|
||||
|
||||
message.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// 회원가입 함수
|
||||
const register = async (userData) => {
|
||||
dispatch({ type: AUTH_ACTIONS.REGISTER_START });
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/auth/register/', userData);
|
||||
|
||||
const { user, tokens } = response.data;
|
||||
|
||||
// 토큰 저장
|
||||
localStorage.setItem('access_token', tokens.access);
|
||||
localStorage.setItem('refresh_token', tokens.refresh);
|
||||
|
||||
// API 헤더에 토큰 설정
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${tokens.access}`;
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.REGISTER_SUCCESS,
|
||||
payload: { user },
|
||||
});
|
||||
|
||||
message.success('회원가입이 완료되었습니다.');
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
'회원가입 중 오류가 발생했습니다.';
|
||||
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.REGISTER_FAILURE,
|
||||
payload: errorMessage,
|
||||
});
|
||||
|
||||
message.error(errorMessage);
|
||||
return { success: false, error: error.response?.data };
|
||||
}
|
||||
};
|
||||
|
||||
// 로그아웃 함수
|
||||
const logout = () => {
|
||||
// 토큰 제거
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
|
||||
// API 헤더에서 토큰 제거
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
|
||||
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
||||
|
||||
message.success('로그아웃되었습니다.');
|
||||
};
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
const updateUser = (userData) => {
|
||||
dispatch({
|
||||
type: AUTH_ACTIONS.UPDATE_USER,
|
||||
payload: userData,
|
||||
});
|
||||
};
|
||||
|
||||
// 프로필 업데이트
|
||||
const updateProfile = async (profileData) => {
|
||||
try {
|
||||
const response = await api.put('/api/auth/profile/', profileData);
|
||||
|
||||
// 사용자 정보 새로고침
|
||||
const userResponse = await api.get('/api/auth/user/');
|
||||
updateUser(userResponse.data);
|
||||
|
||||
message.success('프로필이 업데이트되었습니다.');
|
||||
return { success: true, data: response.data };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
'프로필 업데이트 중 오류가 발생했습니다.';
|
||||
message.error(errorMessage);
|
||||
return { success: false, error: error.response?.data };
|
||||
}
|
||||
};
|
||||
|
||||
// OpenAI API 키 검증
|
||||
const checkApiKey = async () => {
|
||||
try {
|
||||
const response = await api.post('/api/auth/check-api-key/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { valid: false, message: error.response?.data?.message || '검증 실패' };
|
||||
}
|
||||
};
|
||||
|
||||
// Context Value
|
||||
const value = {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
updateProfile,
|
||||
checkApiKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Hook
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// WebSocket Context
|
||||
const WebSocketContext = createContext();
|
||||
|
||||
export const WebSocketProvider = ({ children }) => {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [analysisProgress, setAnalysisProgress] = useState({});
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
// WebSocket 연결 함수
|
||||
const connect = () => {
|
||||
if (!isAuthenticated || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wsUrl = `ws://localhost:8000/ws/trading-analysis/?token=${token}`;
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log('WebSocket 연결됨');
|
||||
setConnected(true);
|
||||
setReconnectAttempts(0);
|
||||
|
||||
// 연결 상태 확인용 ping
|
||||
sendMessage({ type: 'ping', timestamp: Date.now() });
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMessage(data);
|
||||
} catch (error) {
|
||||
console.error('WebSocket 메시지 파싱 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log('WebSocket 연결 해제:', event.code, event.reason);
|
||||
setConnected(false);
|
||||
|
||||
// 자동 재연결 시도 (정상적인 종료가 아닌 경우)
|
||||
if (event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = Math.pow(2, reconnectAttempts) * 1000; // 지수 백오프
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
setReconnectAttempts(prev => prev + 1);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('WebSocket 오류:', error);
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('WebSocket 연결 실패:', error);
|
||||
setConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket 연결 해제 함수
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'User disconnect');
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
setReconnectAttempts(0);
|
||||
};
|
||||
|
||||
// 메시지 전송 함수
|
||||
const sendMessage = (data) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data));
|
||||
} else {
|
||||
console.warn('WebSocket이 연결되지 않음');
|
||||
}
|
||||
};
|
||||
|
||||
// 분석 세션 구독
|
||||
const subscribeToAnalysis = (sessionId) => {
|
||||
sendMessage({
|
||||
type: 'subscribe_analysis',
|
||||
session_id: sessionId
|
||||
});
|
||||
};
|
||||
|
||||
// 메시지 처리 함수
|
||||
const handleMessage = (data) => {
|
||||
console.log('WebSocket 메시지 수신:', data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'connection_established':
|
||||
console.log('WebSocket 연결 설정됨:', data.message);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// ping에 대한 응답
|
||||
break;
|
||||
|
||||
case 'analysis_started':
|
||||
setAnalysisProgress(prev => ({
|
||||
...prev,
|
||||
[data.session_id]: {
|
||||
status: 'running',
|
||||
message: data.message,
|
||||
progress: 0
|
||||
}
|
||||
}));
|
||||
message.info(data.message);
|
||||
break;
|
||||
|
||||
case 'analysis_progress':
|
||||
setAnalysisProgress(prev => ({
|
||||
...prev,
|
||||
[data.session_id]: {
|
||||
...prev[data.session_id],
|
||||
message: data.content,
|
||||
agent: data.agent,
|
||||
progress: prev[data.session_id]?.progress + 10 || 10
|
||||
}
|
||||
}));
|
||||
|
||||
// 새로운 메시지 추가
|
||||
setMessages(prev => [...prev.slice(-50), {
|
||||
id: Date.now(),
|
||||
timestamp: new Date(),
|
||||
type: data.message_type,
|
||||
content: data.content,
|
||||
agent: data.agent,
|
||||
sessionId: data.session_id
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'analysis_completed':
|
||||
setAnalysisProgress(prev => ({
|
||||
...prev,
|
||||
[data.session_id]: {
|
||||
status: 'completed',
|
||||
message: data.message,
|
||||
progress: 100,
|
||||
result: data.result
|
||||
}
|
||||
}));
|
||||
message.success(data.message);
|
||||
break;
|
||||
|
||||
case 'analysis_failed':
|
||||
setAnalysisProgress(prev => ({
|
||||
...prev,
|
||||
[data.session_id]: {
|
||||
status: 'failed',
|
||||
message: data.message,
|
||||
progress: 0,
|
||||
error: data.message
|
||||
}
|
||||
}));
|
||||
message.error(data.message);
|
||||
break;
|
||||
|
||||
case 'subscription_confirmed':
|
||||
console.log(`분석 세션 ${data.session_id} 구독 완료`);
|
||||
break;
|
||||
|
||||
case 'subscription_failed':
|
||||
message.error(data.message);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
message.error(data.message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('알 수 없는 메시지 타입:', data.type);
|
||||
}
|
||||
};
|
||||
|
||||
// 분석 진행 상황 초기화
|
||||
const clearAnalysisProgress = (sessionId) => {
|
||||
setAnalysisProgress(prev => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[sessionId];
|
||||
return newProgress;
|
||||
});
|
||||
};
|
||||
|
||||
// 메시지 목록 초기화
|
||||
const clearMessages = () => {
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
// 사용자 인증 상태 변경 시 WebSocket 연결/해제
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
// 컴포넌트 언마운트 시 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Context 값
|
||||
const value = {
|
||||
connected,
|
||||
messages,
|
||||
analysisProgress,
|
||||
sendMessage,
|
||||
subscribeToAnalysis,
|
||||
clearAnalysisProgress,
|
||||
clearMessages,
|
||||
reconnectAttempts,
|
||||
maxReconnectAttempts
|
||||
};
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={value}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Hook
|
||||
export const useWebSocket = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useWebSocket must be used within a WebSocketProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/* Reset and base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const AnalysisContainer = styled.div`
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const PlaceholderCard = styled(Card)`
|
||||
text-align: center;
|
||||
padding: ${props => props.theme.spacing.xl};
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
`;
|
||||
|
||||
const Analysis = () => {
|
||||
return (
|
||||
<AnalysisContainer>
|
||||
<PlaceholderCard>
|
||||
<Title level={2}>분석 시작 페이지</Title>
|
||||
<Text type="secondary">
|
||||
여기에 거래 분석을 시작할 수 있는 폼이 들어갑니다.
|
||||
<br />
|
||||
종목 선택, 분석 옵션 설정, 분석가 선택 등의 기능이 포함됩니다.
|
||||
</Text>
|
||||
</PlaceholderCard>
|
||||
</AnalysisContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analysis;
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Typography, Button, Table, Tag, Space } from 'antd';
|
||||
import {
|
||||
LineChartOutlined,
|
||||
TrophyOutlined,
|
||||
ClockCircleOutlined,
|
||||
RocketOutlined,
|
||||
PlayCircleOutlined,
|
||||
EyeOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useWebSocket } from '../../contexts/WebSocketContext';
|
||||
import { tradingAPI } from '../../services/api';
|
||||
import styled from 'styled-components';
|
||||
import Loading from '../../components/Loading/Loading';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const DashboardContainer = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const WelcomeCard = styled(Card)`
|
||||
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
|
||||
color: white;
|
||||
margin-bottom: ${props => props.theme.spacing.lg};
|
||||
border: none;
|
||||
|
||||
.ant-card-body {
|
||||
padding: ${props => props.theme.spacing.xl};
|
||||
}
|
||||
`;
|
||||
|
||||
const WelcomeContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: ${props => props.theme.breakpoints.md}) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: ${props => props.theme.spacing.lg};
|
||||
}
|
||||
`;
|
||||
|
||||
const WelcomeText = styled.div`
|
||||
h2 {
|
||||
color: white !important;
|
||||
margin-bottom: ${props => props.theme.spacing.sm};
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: ${props => props.theme.typography.fontSize.lg};
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuickActions = styled.div`
|
||||
display: flex;
|
||||
gap: ${props => props.theme.spacing.md};
|
||||
|
||||
@media (max-width: ${props => props.theme.breakpoints.sm}) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatsCard = styled(Card)`
|
||||
height: 100%;
|
||||
|
||||
.ant-statistic-title {
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
color: ${props => props.theme.colors.text};
|
||||
}
|
||||
`;
|
||||
|
||||
const RecentAnalysisCard = styled(Card)`
|
||||
.ant-card-head-title {
|
||||
font-weight: ${props => props.theme.typography.fontWeight.semibold};
|
||||
}
|
||||
`;
|
||||
|
||||
const Dashboard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
totalAnalyses: 0,
|
||||
runningAnalyses: 0,
|
||||
completedAnalyses: 0,
|
||||
thisMonth: 0
|
||||
});
|
||||
const [recentAnalyses, setRecentAnalyses] = useState([]);
|
||||
const { user } = useAuth();
|
||||
const { connected, analysisProgress } = useWebSocket();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 분석 기록 가져오기
|
||||
const historyResponse = await tradingAPI.getAnalysisHistory();
|
||||
const analyses = historyResponse.data.results || [];
|
||||
|
||||
// 실행 중인 분석 가져오기
|
||||
const runningResponse = await tradingAPI.getRunningAnalyses();
|
||||
const runningAnalyses = runningResponse.data.results || [];
|
||||
|
||||
// 통계 계산
|
||||
const totalCount = analyses.length;
|
||||
const runningCount = runningAnalyses.length;
|
||||
const completedCount = analyses.filter(a => a.status === 'completed').length;
|
||||
|
||||
// 이번 달 분석 수
|
||||
const currentMonth = new Date().getMonth();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const thisMonthCount = analyses.filter(analysis => {
|
||||
const analysisDate = new Date(analysis.created_at);
|
||||
return analysisDate.getMonth() === currentMonth &&
|
||||
analysisDate.getFullYear() === currentYear;
|
||||
}).length;
|
||||
|
||||
setStats({
|
||||
totalAnalyses: totalCount,
|
||||
runningAnalyses: runningCount,
|
||||
completedAnalyses: completedCount,
|
||||
thisMonth: thisMonthCount
|
||||
});
|
||||
|
||||
// 최근 분석 5개만 표시
|
||||
setRecentAnalyses(analyses.slice(0, 5));
|
||||
|
||||
} catch (error) {
|
||||
console.error('대시보드 데이터 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
running: 'blue',
|
||||
completed: 'green',
|
||||
failed: 'red',
|
||||
cancelled: 'default'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '대기 중',
|
||||
running: '실행 중',
|
||||
completed: '완료',
|
||||
failed: '실패',
|
||||
cancelled: '취소됨'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '종목',
|
||||
dataIndex: 'ticker',
|
||||
key: 'ticker',
|
||||
render: (ticker) => <strong>{ticker}</strong>
|
||||
},
|
||||
{
|
||||
title: '분석 날짜',
|
||||
dataIndex: 'analysis_date',
|
||||
key: 'analysis_date',
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<Tag color={getStatusColor(status)}>
|
||||
{getStatusText(status)}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '생성일',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (date) => new Date(date).toLocaleDateString('ko-KR')
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/history`)}
|
||||
>
|
||||
보기
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <Loading text="대시보드를 로드하는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
{/* 환영 메시지 */}
|
||||
<WelcomeCard>
|
||||
<WelcomeContent>
|
||||
<WelcomeText>
|
||||
<Title level={2}>
|
||||
안녕하세요, {user?.first_name || user?.username}님! 👋
|
||||
</Title>
|
||||
<Text>
|
||||
AI 기반 거래 분석으로 더 나은 투자 결정을 내려보세요.
|
||||
</Text>
|
||||
</WelcomeText>
|
||||
|
||||
<QuickActions>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/analysis')}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
새 분석 시작
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate('/history')}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
분석 기록
|
||||
</Button>
|
||||
</QuickActions>
|
||||
</WelcomeContent>
|
||||
</WelcomeCard>
|
||||
|
||||
{/* 통계 카드들 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatsCard>
|
||||
<Statistic
|
||||
title="총 분석 수"
|
||||
value={stats.totalAnalyses}
|
||||
prefix={<LineChartOutlined />}
|
||||
/>
|
||||
</StatsCard>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatsCard>
|
||||
<Statistic
|
||||
title="실행 중"
|
||||
value={stats.runningAnalyses}
|
||||
prefix={<PlayCircleOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</StatsCard>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatsCard>
|
||||
<Statistic
|
||||
title="완료된 분석"
|
||||
value={stats.completedAnalyses}
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</StatsCard>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<StatsCard>
|
||||
<Statistic
|
||||
title="이번 달"
|
||||
value={stats.thisMonth}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</StatsCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 최근 분석 */}
|
||||
<RecentAnalysisCard
|
||||
title="최근 분석"
|
||||
extra={
|
||||
<Button type="link" onClick={() => navigate('/history')}>
|
||||
모두 보기
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{recentAnalyses.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentAnalyses}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text type="secondary">아직 분석 기록이 없습니다.</Text>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: '16px' }}
|
||||
onClick={() => navigate('/analysis')}
|
||||
>
|
||||
첫 번째 분석 시작하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</RecentAnalysisCard>
|
||||
|
||||
{/* WebSocket 연결 상태 정보 */}
|
||||
{!connected && (
|
||||
<Card
|
||||
style={{ marginTop: '16px', borderColor: '#ff4d4f' }}
|
||||
size="small"
|
||||
>
|
||||
<Text type="danger">
|
||||
실시간 업데이트 연결이 끊어졌습니다. 일부 기능이 제한될 수 있습니다.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const HistoryContainer = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const PlaceholderCard = styled(Card)`
|
||||
text-align: center;
|
||||
padding: ${props => props.theme.spacing.xl};
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
`;
|
||||
|
||||
const History = () => {
|
||||
return (
|
||||
<HistoryContainer>
|
||||
<PlaceholderCard>
|
||||
<Title level={2}>분석 기록 페이지</Title>
|
||||
<Text type="secondary">
|
||||
여기에 사용자의 모든 분석 기록이 표시됩니다.
|
||||
<br />
|
||||
테이블 형태로 분석 결과를 확인하고 필터링할 수 있습니다.
|
||||
</Text>
|
||||
</PlaceholderCard>
|
||||
</HistoryContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, Alert, Divider } from 'antd';
|
||||
import { UserOutlined, LockOutlined, LineChartOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const LoginContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
|
||||
padding: ${props => props.theme.spacing.lg};
|
||||
`;
|
||||
|
||||
const LoginCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: ${props => props.theme.shadows.xl};
|
||||
border: none;
|
||||
border-radius: ${props => props.theme.borderRadius.lg};
|
||||
`;
|
||||
|
||||
const LogoSection = styled.div`
|
||||
text-align: center;
|
||||
margin-bottom: ${props => props.theme.spacing.xl};
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${props => props.theme.spacing.sm};
|
||||
margin-bottom: ${props => props.theme.spacing.md};
|
||||
`;
|
||||
|
||||
const LogoIcon = styled(LineChartOutlined)`
|
||||
font-size: 32px;
|
||||
color: ${props => props.theme.colors.primary};
|
||||
`;
|
||||
|
||||
const LogoText = styled(Title)`
|
||||
margin: 0;
|
||||
color: ${props => props.theme.colors.primary};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.bold};
|
||||
`;
|
||||
|
||||
const SubTitle = styled(Text)`
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
`;
|
||||
|
||||
const StyledForm = styled(Form)`
|
||||
.ant-form-item {
|
||||
margin-bottom: ${props => props.theme.spacing.lg};
|
||||
}
|
||||
`;
|
||||
|
||||
const LoginButton = styled(Button)`
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
`;
|
||||
|
||||
const RegisterLink = styled.div`
|
||||
text-align: center;
|
||||
margin-top: ${props => props.theme.spacing.lg};
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
`;
|
||||
|
||||
const Login = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await login(values.email, values.password);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError(result.error || '로그인에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('로그인 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = () => {
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginContainer>
|
||||
<LoginCard>
|
||||
<LogoSection>
|
||||
<Logo>
|
||||
<LogoIcon />
|
||||
<LogoText level={2}>TradingAgents</LogoText>
|
||||
</Logo>
|
||||
<SubTitle>AI 거래 분석 플랫폼에 로그인하세요</SubTitle>
|
||||
</LogoSection>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledForm
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleFormChange}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
label="이메일"
|
||||
name="email"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '이메일을 입력해주세요.',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '올바른 이메일 형식을 입력해주세요.',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="이메일을 입력하세요"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="비밀번호"
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '비밀번호를 입력해주세요.',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<LoginButton
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
>
|
||||
로그인
|
||||
</LoginButton>
|
||||
</Form.Item>
|
||||
</StyledForm>
|
||||
|
||||
<Divider>또는</Divider>
|
||||
|
||||
<RegisterLink>
|
||||
계정이 없으신가요?{' '}
|
||||
<Link to="/register" style={{ fontWeight: 500 }}>
|
||||
회원가입
|
||||
</Link>
|
||||
</RegisterLink>
|
||||
</LoginCard>
|
||||
</LoginContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Card, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const ProfileContainer = styled.div`
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const PlaceholderCard = styled(Card)`
|
||||
text-align: center;
|
||||
padding: ${props => props.theme.spacing.xl};
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
`;
|
||||
|
||||
const Profile = () => {
|
||||
return (
|
||||
<ProfileContainer>
|
||||
<PlaceholderCard>
|
||||
<Title level={2}>프로필 설정 페이지</Title>
|
||||
<Text type="secondary">
|
||||
여기에 사용자 프로필 설정 기능이 들어갑니다.
|
||||
<br />
|
||||
개인정보 수정, OpenAI API 키 설정, 기본 분석 옵션 설정 등이 포함됩니다.
|
||||
</Text>
|
||||
</PlaceholderCard>
|
||||
</ProfileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, Alert, Divider } from 'antd';
|
||||
import { UserOutlined, LockOutlined, MailOutlined, LineChartOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const RegisterContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
|
||||
padding: ${props => props.theme.spacing.lg};
|
||||
`;
|
||||
|
||||
const RegisterCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: ${props => props.theme.shadows.xl};
|
||||
border: none;
|
||||
border-radius: ${props => props.theme.borderRadius.lg};
|
||||
`;
|
||||
|
||||
const LogoSection = styled.div`
|
||||
text-align: center;
|
||||
margin-bottom: ${props => props.theme.spacing.xl};
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${props => props.theme.spacing.sm};
|
||||
margin-bottom: ${props => props.theme.spacing.md};
|
||||
`;
|
||||
|
||||
const LogoIcon = styled(LineChartOutlined)`
|
||||
font-size: 32px;
|
||||
color: ${props => props.theme.colors.primary};
|
||||
`;
|
||||
|
||||
const LogoText = styled(Title)`
|
||||
margin: 0;
|
||||
color: ${props => props.theme.colors.primary};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.bold};
|
||||
`;
|
||||
|
||||
const SubTitle = styled(Text)`
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
`;
|
||||
|
||||
const StyledForm = styled(Form)`
|
||||
.ant-form-item {
|
||||
margin-bottom: ${props => props.theme.spacing.md};
|
||||
}
|
||||
`;
|
||||
|
||||
const RegisterButton = styled(Button)`
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
`;
|
||||
|
||||
const LoginLink = styled.div`
|
||||
text-align: center;
|
||||
margin-top: ${props => props.theme.spacing.lg};
|
||||
color: ${props => props.theme.colors.textSecondary};
|
||||
`;
|
||||
|
||||
const Register = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const result = await register({
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
first_name: values.firstName,
|
||||
last_name: values.lastName,
|
||||
password: values.password,
|
||||
password_confirm: values.confirmPassword,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
if (result.error && typeof result.error === 'object') {
|
||||
setErrors(result.error);
|
||||
} else {
|
||||
setErrors({ general: result.error || '회원가입에 실패했습니다.' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrors({ general: '회원가입 중 오류가 발생했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = () => {
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 에러 메시지 포맷팅
|
||||
const getErrorMessage = (fieldName) => {
|
||||
const error = errors[fieldName];
|
||||
if (Array.isArray(error)) {
|
||||
return error[0];
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
return (
|
||||
<RegisterContainer>
|
||||
<RegisterCard>
|
||||
<LogoSection>
|
||||
<Logo>
|
||||
<LogoIcon />
|
||||
<LogoText level={2}>TradingAgents</LogoText>
|
||||
</Logo>
|
||||
<SubTitle>AI 거래 분석 플랫폼에 가입하세요</SubTitle>
|
||||
</LogoSection>
|
||||
|
||||
{errors.general && (
|
||||
<Alert
|
||||
message={errors.general}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledForm
|
||||
form={form}
|
||||
name="register"
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleFormChange}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
label="이메일"
|
||||
name="email"
|
||||
validateStatus={errors.email ? 'error' : ''}
|
||||
help={getErrorMessage('email')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '이메일을 입력해주세요.',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '올바른 이메일 형식을 입력해주세요.',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="이메일을 입력하세요"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="사용자명"
|
||||
name="username"
|
||||
validateStatus={errors.username ? 'error' : ''}
|
||||
help={getErrorMessage('username')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '사용자명을 입력해주세요.',
|
||||
},
|
||||
{
|
||||
min: 3,
|
||||
message: '사용자명은 최소 3자 이상이어야 합니다.',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="사용자명을 입력하세요"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<Form.Item
|
||||
label="성"
|
||||
name="lastName"
|
||||
validateStatus={errors.last_name ? 'error' : ''}
|
||||
help={getErrorMessage('last_name')}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="성을 입력하세요" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="이름"
|
||||
name="firstName"
|
||||
validateStatus={errors.first_name ? 'error' : ''}
|
||||
help={getErrorMessage('first_name')}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="이름을 입력하세요" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="비밀번호"
|
||||
name="password"
|
||||
validateStatus={errors.password ? 'error' : ''}
|
||||
help={getErrorMessage('password')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '비밀번호를 입력해주세요.',
|
||||
},
|
||||
{
|
||||
min: 8,
|
||||
message: '비밀번호는 최소 8자 이상이어야 합니다.',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="비밀번호 확인"
|
||||
name="confirmPassword"
|
||||
validateStatus={errors.password_confirm ? 'error' : ''}
|
||||
help={getErrorMessage('password_confirm')}
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '비밀번호 확인을 입력해주세요.',
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('비밀번호가 일치하지 않습니다.'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: '24px' }}>
|
||||
<RegisterButton
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
>
|
||||
회원가입
|
||||
</RegisterButton>
|
||||
</Form.Item>
|
||||
</StyledForm>
|
||||
|
||||
<Divider>또는</Divider>
|
||||
|
||||
<LoginLink>
|
||||
이미 계정이 있으신가요?{' '}
|
||||
<Link to="/login" style={{ fontWeight: 500 }}>
|
||||
로그인
|
||||
</Link>
|
||||
</LoginLink>
|
||||
</RegisterCard>
|
||||
</RegisterContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import axios from 'axios';
|
||||
import { message } from 'antd';
|
||||
|
||||
// API 베이스 URL
|
||||
const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 요청 인터셉터
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 토큰이 있으면 헤더에 추가
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('API 요청 오류:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 응답 인터셉터
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`API 응답: ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
console.error('API 응답 오류:', error.response?.status, error.response?.data);
|
||||
|
||||
// 401 오류 (인증 실패) 처리
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
// 토큰 갱신 시도
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/api/auth/token/refresh/`,
|
||||
{ refresh: refreshToken }
|
||||
);
|
||||
|
||||
const newToken = response.data.access;
|
||||
localStorage.setItem('access_token', newToken);
|
||||
|
||||
// 원래 요청에 새 토큰 적용
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
|
||||
return api(originalRequest);
|
||||
|
||||
} catch (refreshError) {
|
||||
console.error('토큰 갱신 실패:', refreshError);
|
||||
|
||||
// 리프레시 토큰도 만료된 경우 로그아웃 처리
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
|
||||
// 로그인 페이지로 리디렉션
|
||||
window.location.href = '/login';
|
||||
|
||||
message.error('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
} else {
|
||||
// 리프레시 토큰이 없는 경우 로그아웃 처리
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
|
||||
window.location.href = '/login';
|
||||
message.error('인증이 필요합니다. 로그인해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// 다른 오류들 처리
|
||||
if (error.response?.status >= 500) {
|
||||
message.error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} else if (error.response?.status === 403) {
|
||||
message.error('접근 권한이 없습니다.');
|
||||
} else if (error.response?.status === 404) {
|
||||
message.error('요청한 리소스를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// API 함수들
|
||||
export const authAPI = {
|
||||
// 로그인
|
||||
login: (email, password) =>
|
||||
api.post('/api/auth/login/', { email, password }),
|
||||
|
||||
// 회원가입
|
||||
register: (userData) =>
|
||||
api.post('/api/auth/register/', userData),
|
||||
|
||||
// 사용자 정보 조회
|
||||
getUser: () =>
|
||||
api.get('/api/auth/user/'),
|
||||
|
||||
// 프로필 조회
|
||||
getProfile: () =>
|
||||
api.get('/api/auth/profile/'),
|
||||
|
||||
// 프로필 업데이트
|
||||
updateProfile: (profileData) =>
|
||||
api.put('/api/auth/profile/', profileData),
|
||||
|
||||
// OpenAI API 키 검증
|
||||
checkApiKey: () =>
|
||||
api.post('/api/auth/check-api-key/'),
|
||||
|
||||
// OpenAI API 키 제거
|
||||
removeApiKey: () =>
|
||||
api.delete('/api/auth/remove-api-key/'),
|
||||
|
||||
// 분석 세션 목록
|
||||
getAnalysisSessions: () =>
|
||||
api.get('/api/auth/sessions/'),
|
||||
};
|
||||
|
||||
export const tradingAPI = {
|
||||
// 분석 설정 정보 조회
|
||||
getAnalysisConfig: () =>
|
||||
api.get('/api/trading/config/'),
|
||||
|
||||
// 분석 옵션 조회
|
||||
getAnalysisOptions: () =>
|
||||
api.get('/api/trading/options/'),
|
||||
|
||||
// 분석 시작
|
||||
startAnalysis: (analysisData) =>
|
||||
api.post('/api/trading/start/', analysisData),
|
||||
|
||||
// 분석 상태 조회
|
||||
getAnalysisStatus: (sessionId) =>
|
||||
api.get(`/api/trading/status/${sessionId}/`),
|
||||
|
||||
// 분석 취소
|
||||
cancelAnalysis: (sessionId) =>
|
||||
api.post(`/api/trading/cancel/${sessionId}/`),
|
||||
|
||||
// 분석 기록 조회
|
||||
getAnalysisHistory: () =>
|
||||
api.get('/api/trading/history/'),
|
||||
|
||||
// 분석 보고서 조회
|
||||
getAnalysisReport: (sessionId) =>
|
||||
api.get(`/api/trading/report/${sessionId}/`),
|
||||
|
||||
// 실행 중인 분석 조회
|
||||
getRunningAnalyses: () =>
|
||||
api.get('/api/trading/running/'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ${props => props.theme.typography.fontFamily};
|
||||
color: ${props => props.theme.colors.text};
|
||||
background-color: ${props => props.theme.colors.background};
|
||||
font-size: ${props => props.theme.typography.fontSize.base};
|
||||
line-height: ${props => props.theme.typography.lineHeight.normal};
|
||||
}
|
||||
|
||||
/* Link Styles */
|
||||
a {
|
||||
color: ${props => props.theme.colors.primary};
|
||||
text-decoration: none;
|
||||
transition: color ${props => props.theme.transitions.fast};
|
||||
|
||||
&:hover {
|
||||
color: ${props => props.theme.colors.primaryHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: ${props => props.theme.colors.primaryActive};
|
||||
}
|
||||
}
|
||||
|
||||
/* Button Reset */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Input Reset */
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Remove outline on focus for non-keyboard users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: ${props => props.theme.colors.backgroundTertiary};
|
||||
border-radius: ${props => props.theme.borderRadius.sm};
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${props => props.theme.colors.border};
|
||||
border-radius: ${props => props.theme.borderRadius.sm};
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.theme.colors.textSecondary};
|
||||
}
|
||||
}
|
||||
|
||||
/* Ant Design Customizations */
|
||||
.ant-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.ant-layout-header {
|
||||
background: ${props => props.theme.colors.background};
|
||||
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
|
||||
padding: 0 ${props => props.theme.spacing.lg};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
background: ${props => props.theme.colors.background};
|
||||
border-right: 1px solid ${props => props.theme.colors.borderLight};
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
background: ${props => props.theme.colors.backgroundSecondary};
|
||||
padding: ${props => props.theme.spacing.lg};
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
margin: 0;
|
||||
border-radius: ${props => props.theme.borderRadius.base};
|
||||
margin-bottom: ${props => props.theme.spacing.xs};
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => props.theme.colors.primaryLight};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: ${props => props.theme.colors.primaryLight} !important;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
border-radius: ${props => props.theme.borderRadius.md};
|
||||
box-shadow: ${props => props.theme.shadows.sm};
|
||||
border: 1px solid ${props => props.theme.colors.borderLight};
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border-radius: ${props => props.theme.borderRadius.base};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
transition: all ${props => props.theme.transitions.fast};
|
||||
}
|
||||
|
||||
.ant-btn-primary {
|
||||
background-color: ${props => props.theme.colors.primary};
|
||||
border-color: ${props => props.theme.colors.primary};
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: ${props => props.theme.colors.primaryHover};
|
||||
border-color: ${props => props.theme.colors.primaryHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: ${props => props.theme.colors.primaryActive};
|
||||
border-color: ${props => props.theme.colors.primaryActive};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input, .ant-input-password, .ant-select-selector {
|
||||
border-radius: ${props => props.theme.borderRadius.base};
|
||||
transition: all ${props => props.theme.transitions.fast};
|
||||
|
||||
&:hover {
|
||||
border-color: ${props => props.theme.colors.primaryHover};
|
||||
}
|
||||
|
||||
&:focus, &.ant-input-focused, &.ant-select-focused .ant-select-selector {
|
||||
border-color: ${props => props.theme.colors.primary};
|
||||
box-shadow: 0 0 0 2px ${props => props.theme.colors.primaryLight};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
color: ${props => props.theme.colors.text};
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
border-radius: ${props => props.theme.borderRadius.md};
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: ${props => props.theme.colors.backgroundTertiary};
|
||||
border-bottom: 1px solid ${props => props.theme.colors.borderLight};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.semibold};
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background-color: ${props => props.theme.colors.backgroundSecondary};
|
||||
}
|
||||
|
||||
.ant-progress-line {
|
||||
.ant-progress-bg {
|
||||
transition: all ${props => props.theme.transitions.base};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
border-radius: ${props => props.theme.borderRadius.base};
|
||||
font-weight: ${props => props.theme.typography.fontWeight.medium};
|
||||
}
|
||||
|
||||
.ant-notification {
|
||||
.ant-notification-notice {
|
||||
border-radius: ${props => props.theme.borderRadius.md};
|
||||
box-shadow: ${props => props.theme.shadows.lg};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-message {
|
||||
.ant-message-notice-content {
|
||||
border-radius: ${props => props.theme.borderRadius.md};
|
||||
box-shadow: ${props => props.theme.shadows.md};
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Status Colors */
|
||||
.status-pending {
|
||||
color: ${props => props.theme.colors.pending};
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: ${props => props.theme.colors.running};
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
color: ${props => props.theme.colors.completed};
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: ${props => props.theme.colors.failed};
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
color: ${props => props.theme.colors.cancelled};
|
||||
}
|
||||
|
||||
/* Trading Colors */
|
||||
.bullish {
|
||||
color: ${props => props.theme.colors.bullish};
|
||||
}
|
||||
|
||||
.bearish {
|
||||
color: ${props => props.theme.colors.bearish};
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: ${props => props.theme.colors.neutral};
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mb-0 { margin-bottom: 0 !important; }
|
||||
.mb-1 { margin-bottom: ${props => props.theme.spacing.xs} !important; }
|
||||
.mb-2 { margin-bottom: ${props => props.theme.spacing.sm} !important; }
|
||||
.mb-3 { margin-bottom: ${props => props.theme.spacing.md} !important; }
|
||||
.mb-4 { margin-bottom: ${props => props.theme.spacing.lg} !important; }
|
||||
.mb-5 { margin-bottom: ${props => props.theme.spacing.xl} !important; }
|
||||
|
||||
.mt-0 { margin-top: 0 !important; }
|
||||
.mt-1 { margin-top: ${props => props.theme.spacing.xs} !important; }
|
||||
.mt-2 { margin-top: ${props => props.theme.spacing.sm} !important; }
|
||||
.mt-3 { margin-top: ${props => props.theme.spacing.md} !important; }
|
||||
.mt-4 { margin-top: ${props => props.theme.spacing.lg} !important; }
|
||||
.mt-5 { margin-top: ${props => props.theme.spacing.xl} !important; }
|
||||
|
||||
.ml-0 { margin-left: 0 !important; }
|
||||
.ml-1 { margin-left: ${props => props.theme.spacing.xs} !important; }
|
||||
.ml-2 { margin-left: ${props => props.theme.spacing.sm} !important; }
|
||||
.ml-3 { margin-left: ${props => props.theme.spacing.md} !important; }
|
||||
.ml-4 { margin-left: ${props => props.theme.spacing.lg} !important; }
|
||||
|
||||
.mr-0 { margin-right: 0 !important; }
|
||||
.mr-1 { margin-right: ${props => props.theme.spacing.xs} !important; }
|
||||
.mr-2 { margin-right: ${props => props.theme.spacing.sm} !important; }
|
||||
.mr-3 { margin-right: ${props => props.theme.spacing.md} !important; }
|
||||
.mr-4 { margin-right: ${props => props.theme.spacing.lg} !important; }
|
||||
|
||||
.p-0 { padding: 0 !important; }
|
||||
.p-1 { padding: ${props => props.theme.spacing.xs} !important; }
|
||||
.p-2 { padding: ${props => props.theme.spacing.sm} !important; }
|
||||
.p-3 { padding: ${props => props.theme.spacing.md} !important; }
|
||||
.p-4 { padding: ${props => props.theme.spacing.lg} !important; }
|
||||
|
||||
/* Loading Animation */
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Fade Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn ${props => props.theme.transitions.base};
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fadeOut ${props => props.theme.transitions.base};
|
||||
}
|
||||
|
||||
/* Responsive Utilities */
|
||||
@media (max-width: ${props => props.theme.breakpoints.sm}) {
|
||||
.ant-layout-content {
|
||||
padding: ${props => props.theme.spacing.md};
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
margin-bottom: ${props => props.theme.spacing.md};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default GlobalStyle;
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
// 테마 설정
|
||||
const theme = {
|
||||
colors: {
|
||||
// Primary Colors
|
||||
primary: '#1890ff',
|
||||
primaryHover: '#40a9ff',
|
||||
primaryActive: '#096dd9',
|
||||
primaryLight: '#e6f7ff',
|
||||
|
||||
// Secondary Colors
|
||||
secondary: '#722ed1',
|
||||
secondaryHover: '#9254de',
|
||||
secondaryActive: '#531dab',
|
||||
|
||||
// Success Colors
|
||||
success: '#52c41a',
|
||||
successHover: '#73d13d',
|
||||
successActive: '#389e0d',
|
||||
successLight: '#f6ffed',
|
||||
|
||||
// Warning Colors
|
||||
warning: '#fa8c16',
|
||||
warningHover: '#ffa940',
|
||||
warningActive: '#d46b08',
|
||||
warningLight: '#fff7e6',
|
||||
|
||||
// Error Colors
|
||||
error: '#ff4d4f',
|
||||
errorHover: '#ff7875',
|
||||
errorActive: '#d9363e',
|
||||
errorLight: '#fff2f0',
|
||||
|
||||
// Info Colors
|
||||
info: '#1890ff',
|
||||
infoHover: '#40a9ff',
|
||||
infoActive: '#096dd9',
|
||||
infoLight: '#e6f7ff',
|
||||
|
||||
// Neutral Colors
|
||||
text: '#262626',
|
||||
textSecondary: '#8c8c8c',
|
||||
textLight: '#bfbfbf',
|
||||
textDisabled: '#d9d9d9',
|
||||
|
||||
// Background Colors
|
||||
background: '#ffffff',
|
||||
backgroundSecondary: '#fafafa',
|
||||
backgroundTertiary: '#f5f5f5',
|
||||
|
||||
// Border Colors
|
||||
border: '#d9d9d9',
|
||||
borderLight: '#f0f0f0',
|
||||
borderSecondary: '#e6f7ff',
|
||||
|
||||
// Card & Surface Colors
|
||||
cardBg: '#ffffff',
|
||||
surfaceBg: '#fafafa',
|
||||
|
||||
// Trading Specific Colors
|
||||
bullish: '#52c41a',
|
||||
bearish: '#ff4d4f',
|
||||
neutral: '#fa8c16',
|
||||
|
||||
// Analysis Status Colors
|
||||
pending: '#faad14',
|
||||
running: '#1890ff',
|
||||
completed: '#52c41a',
|
||||
failed: '#ff4d4f',
|
||||
cancelled: '#8c8c8c',
|
||||
},
|
||||
|
||||
typography: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
|
||||
// Font Sizes
|
||||
fontSize: {
|
||||
xs: '12px',
|
||||
sm: '14px',
|
||||
base: '16px',
|
||||
lg: '18px',
|
||||
xl: '20px',
|
||||
'2xl': '24px',
|
||||
'3xl': '28px',
|
||||
'4xl': '32px',
|
||||
'5xl': '36px',
|
||||
},
|
||||
|
||||
// Font Weights
|
||||
fontWeight: {
|
||||
light: 300,
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
},
|
||||
|
||||
// Line Heights
|
||||
lineHeight: {
|
||||
tight: 1.2,
|
||||
normal: 1.5,
|
||||
relaxed: 1.75,
|
||||
},
|
||||
},
|
||||
|
||||
spacing: {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px',
|
||||
'2xl': '48px',
|
||||
'3xl': '64px',
|
||||
'4xl': '96px',
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
none: '0',
|
||||
sm: '2px',
|
||||
base: '6px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
'2xl': '24px',
|
||||
full: '50%',
|
||||
},
|
||||
|
||||
shadows: {
|
||||
none: 'none',
|
||||
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
base: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
md: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
lg: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
xl: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
|
||||
breakpoints: {
|
||||
xs: '480px',
|
||||
sm: '576px',
|
||||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px',
|
||||
xxl: '1600px',
|
||||
},
|
||||
|
||||
zIndex: {
|
||||
dropdown: 1000,
|
||||
sticky: 1020,
|
||||
fixed: 1030,
|
||||
modalBackdrop: 1040,
|
||||
modal: 1050,
|
||||
popover: 1060,
|
||||
tooltip: 1070,
|
||||
notification: 1080,
|
||||
},
|
||||
|
||||
transitions: {
|
||||
fast: '150ms ease-in-out',
|
||||
base: '250ms ease-in-out',
|
||||
slow: '350ms ease-in-out',
|
||||
},
|
||||
|
||||
// Component Specific Themes
|
||||
components: {
|
||||
button: {
|
||||
height: {
|
||||
sm: '24px',
|
||||
base: '32px',
|
||||
lg: '40px',
|
||||
},
|
||||
padding: {
|
||||
sm: '4px 15px',
|
||||
base: '4px 15px',
|
||||
lg: '6px 15px',
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
height: {
|
||||
sm: '24px',
|
||||
base: '32px',
|
||||
lg: '40px',
|
||||
},
|
||||
},
|
||||
|
||||
card: {
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
|
||||
layout: {
|
||||
header: {
|
||||
height: '64px',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
},
|
||||
sidebar: {
|
||||
width: '200px',
|
||||
collapsedWidth: '80px',
|
||||
background: '#001529',
|
||||
},
|
||||
content: {
|
||||
padding: '24px',
|
||||
background: '#fafafa',
|
||||
minHeight: 'calc(100vh - 64px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
||||
Loading…
Reference in New Issue