be,fe
This commit is contained in:
parent
3ff3ed5d9a
commit
4771c73c24
|
|
@ -1,8 +1,120 @@
|
||||||
env/
|
# 환경 변수 파일
|
||||||
|
.env
|
||||||
|
web/backend/.env
|
||||||
|
*.env
|
||||||
|
env_local.txt
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.DS_Store
|
*.py[cod]
|
||||||
*.csv
|
*$py.class
|
||||||
src/
|
*.so
|
||||||
eval_results/
|
.Python
|
||||||
eval_data/
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
*.egg-info/
|
*.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},
|
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
|
typing-extensions
|
||||||
langchain-openai
|
langchain-openai
|
||||||
langchain-experimental
|
langchain-experimental
|
||||||
|
|
@ -7,7 +26,6 @@ praw
|
||||||
feedparser
|
feedparser
|
||||||
stockstats
|
stockstats
|
||||||
eodhd
|
eodhd
|
||||||
langgraph
|
|
||||||
chromadb
|
chromadb
|
||||||
setuptools
|
setuptools
|
||||||
backtrader
|
backtrader
|
||||||
|
|
@ -22,3 +40,4 @@ redis
|
||||||
chainlit
|
chainlit
|
||||||
rich
|
rich
|
||||||
questionary
|
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