This commit is contained in:
kimheesu 2025-06-13 14:23:04 +09:00
parent fff3166d48
commit 9ba0b349ce
56 changed files with 31914 additions and 7 deletions

124
.gitignore vendored
View File

@ -1,8 +1,120 @@
env/
# 환경 변수 파일
.env
web/backend/.env
*.env
env_local.txt
# Python
__pycache__/
.DS_Store
*.csv
src/
eval_results/
eval_data/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
/staticfiles/
/media/
# Virtual Environment
venv/
env/
ENV/
env.bak/
venv.bak/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Node.js (React)
web/frontend/node_modules/
web/frontend/build/
web/frontend/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Docker
docker-compose.override.yml
# Logs
logs/
*.log
# Coverage reports
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Trading specific
trading_data/
analysis_results/
temp_files/

295
README.md
View File

@ -211,3 +211,298 @@ Please reference our work if you find *TradingAgents* provides you with some hel
url={https://arxiv.org/abs/2412.20138},
}
```
# TradingAgents Web Application
CLI 기능을 웹에서 사용할 수 있는 React + Django 웹 애플리케이션입니다.
## 주요 기능
1. **사용자 인증**
- JWT 기반 로그인/회원가입
- OpenAI API 키 관리 (암호화 저장)
- 개발자 기본 키 fallback
2. **거래 분석**
- CLI의 모든 분석 기능을 웹에서 사용
- 실시간 분석 진행 상황 (WebSocket)
- 분석 기록 관리
3. **사용자 경험**
- 현대적인 React UI (Ant Design)
- 반응형 디자인
- 실시간 업데이트
## 기술 스택
### 백엔드
- **Django 4.2** - 웹 프레임워크
- **Django REST Framework** - API 개발
- **Django Channels** - WebSocket 지원
- **MySQL 8.0** - 데이터베이스 (Docker)
- **Redis 7** - WebSocket 메시지 브로커 (Docker)
- **JWT** - 인증
### 프론트엔드
- **React 18** - UI 라이브러리
- **Ant Design** - UI 컴포넌트
- **Styled Components** - 스타일링
- **Axios** - HTTP 클라이언트
- **WebSocket** - 실시간 통신
## 설치 및 실행
### 1. 환경 설정
```bash
# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Python 의존성 설치
pip install -r requirements.txt
# Node.js 의존성 설치
cd web/frontend
npm install
cd ../..
```
### 2. 데이터베이스 및 Redis 설정 (Docker)
Docker와 Docker Compose를 이용해 MySQL과 Redis를 실행합니다.
```bash
# Docker 및 Docker Compose 설치 확인
docker --version
docker-compose --version
# 편의 스크립트 사용 (권장)
chmod +x scripts/docker-commands.sh
./scripts/docker-commands.sh start
# 또는 직접 Docker Compose 명령 사용
docker-compose up -d mysql redis
# phpMyAdmin도 함께 시작 (데이터베이스 관리용)
./scripts/docker-commands.sh start-all
# 컨테이너 상태 확인
./scripts/docker-commands.sh status
```
### 3. 환경 변수 설정
`web/backend/.env` 파일을 생성합니다. `env_example.txt`를 참고하여 설정하세요:
```bash
# 예시 파일을 복사하여 시작
cp web/backend/env_example.txt web/backend/.env
# .env 파일을 편집하여 실제 값들로 변경
nano web/backend/.env # 또는 다른 텍스트 에디터 사용
```
주요 설정값들:
```env
# Django 설정
SECRET_KEY=your-secret-key-here-change-this-to-a-random-string
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# MySQL 데이터베이스 설정 (Docker)
DB_NAME=tradingagents_web
DB_USER=root
DB_PASSWORD=your-mysql-password-here
DB_HOST=127.0.0.1
DB_PORT=3306
# Redis 설정 (Docker)
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# OpenAI API 키 (개발자 기본 키)
OPENAI_API_KEY=your-openai-api-key-here
```
### 4. 데이터베이스 마이그레이션
```bash
cd web/backend
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser # 관리자 계정 생성
```
### 5. 개발 서버 실행
**터미널 1 - Docker 컨테이너 (MySQL + Redis):**
```bash
# 백그라운드에서 실행
docker-compose up -d mysql redis
# 또는 포그라운드에서 로그 확인
docker-compose up mysql redis
```
**터미널 2 - Django 백엔드:**
```bash
cd web/backend
python manage.py runserver
```
**터미널 3 - React 프론트엔드:**
```bash
cd web/frontend
npm start
```
## 접속 정보
- **프론트엔드**: http://localhost:3000
- **백엔드 API**: http://localhost:8000
- **Django Admin**: http://localhost:8000/admin
- **phpMyAdmin** (선택사항): http://localhost:8080
## API 엔드포인트
### 인증
- `POST /api/auth/register/` - 회원가입
- `POST /api/auth/login/` - 로그인
- `GET /api/auth/user/` - 사용자 정보
- `PUT /api/auth/profile/` - 프로필 수정
- `POST /api/auth/check-api-key/` - API 키 검증
### 거래 분석
- `GET /api/trading/config/` - 분석 설정 정보
- `POST /api/trading/start/` - 분석 시작
- `GET /api/trading/status/{id}/` - 분석 상태 조회
- `GET /api/trading/history/` - 분석 기록
- `GET /api/trading/report/{id}/` - 분석 보고서
### WebSocket
- `ws://localhost:8000/ws/trading-analysis/` - 실시간 분석 업데이트
## OpenAI API 키 관리
1. **사용자 개별 키**: 사용자가 프로필에서 설정한 개인 OpenAI API 키
2. **개발자 기본 키**: `.env` 파일의 `OPENAI_API_KEY` (사용자 키가 없을 때 사용)
3. **보안**: 사용자 키는 암호화되어 데이터베이스에 저장
## 프로젝트 구조
```
├── cli/ # 기존 CLI 코드
├── web/
│ ├── backend/ # Django 백엔드
│ │ ├── tradingagents_web/ # 프로젝트 설정
│ │ └── apps/ # Django 앱들
│ │ ├── authentication/ # 사용자 인증
│ │ ├── trading_api/ # 거래 분석 API
│ │ └── websocket/ # WebSocket 처리
│ └── frontend/ # React 프론트엔드
│ ├── public/
│ └── src/
│ ├── components/ # 재사용 컴포넌트
│ ├── contexts/ # React Context
│ ├── pages/ # 페이지 컴포넌트
│ ├── services/ # API 서비스
│ └── styles/ # 스타일 관련
└── requirements.txt # Python 의존성
```
## 개발 가이드
### 새로운 분석 기능 추가
1. `apps/trading_api/services.py`에 새로운 서비스 추가
2. `apps/trading_api/views.py`에 새로운 뷰 추가
3. `apps/trading_api/urls.py`에 URL 패턴 추가
4. 프론트엔드에서 해당 API 호출
### 새로운 페이지 추가
1. `src/pages/` 디렉토리에 새 페이지 컴포넌트 생성
2. `src/App.js`에 라우트 추가
3. 필요한 경우 레이아웃의 메뉴에 추가
## 배포
### Docker Compose (권장)
```bash
# 모든 서비스를 한 번에 시작 (개발 환경)
docker-compose up -d
# 특정 서비스만 시작
docker-compose up -d mysql redis
# 프로덕션 환경에서는 별도의 docker-compose.prod.yml 사용 권장
docker-compose -f docker-compose.prod.yml up -d
```
### 수동 배포
1. **프론트엔드 빌드**:
```bash
cd web/frontend
npm run build
```
2. **Django 정적 파일 수집**:
```bash
cd web/backend
python manage.py collectstatic
```
3. **프로덕션 서버 설정** (Nginx + Gunicorn + Daphne)
## 문제 해결
### 일반적인 문제
1. **Docker 컨테이너 관련**
```bash
# 컨테이너 상태 확인
docker-compose ps
# 컨테이너 로그 확인
docker-compose logs mysql
docker-compose logs redis
# 컨테이너 재시작
docker-compose restart mysql redis
```
2. **WebSocket 연결 실패**
- Redis 컨테이너가 실행 중인지 확인: `docker-compose ps`
- 방화벽 설정 확인
3. **API 키 관련 오류**
- `.env` 파일의 `OPENAI_API_KEY` 확인
- 사용자 프로필에서 API 키 재설정
4. **데이터베이스 연결 오류**
- MySQL 컨테이너 상태 확인: `docker-compose logs mysql`
- `.env` 파일의 데이터베이스 연결 정보 확인
- 컨테이너 포트 충돌 확인: `docker port tradingagents_mysql`
5. **MySQL 컨테이너 초기화 문제**
```bash
# 볼륨 삭제 후 재시작 (데이터 손실 주의!)
docker-compose down -v
docker-compose up -d mysql redis
```
## 라이선스
이 프로젝트는 기존 TradingAgents 프로젝트의 라이선스를 따릅니다.
## 기여
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request

59
docker-compose.yml Normal file
View File

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

View File

@ -1,3 +1,22 @@
# Backend dependencies - Django
Django==4.2.7
django-cors-headers==4.3.1
django-rest-framework==0.1.0
djangorestframework==3.14.0
djangorestframework-simplejwt==5.3.0
python-decouple==3.8
cryptography==41.0.7
mysqlclient==2.2.0
channels==4.0.0
channels-redis
# Existing CLI dependencies
typer
questionary
pydantic
# OpenAI and other AI dependencies
openai
typing-extensions
langchain-openai
langchain-experimental
@ -7,7 +26,6 @@ praw
feedparser
stockstats
eodhd
langgraph
chromadb
setuptools
backtrader
@ -22,3 +40,4 @@ redis
chainlit
rich
questionary
langgraph==0.4.8

104
scripts/docker-commands.sh Normal file
View File

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

View File

@ -0,0 +1 @@
# Apps package

View File

@ -0,0 +1 @@
# Authentication app

View File

@ -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 = '소요시간'

View File

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

View File

@ -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'],
},
),
]

View File

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

View File

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

View File

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

View File

@ -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'),
]

View File

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

View File

@ -0,0 +1 @@

View File

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

View File

@ -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("분석 세션을 찾을 수 없습니다.")

View File

@ -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'),
]

View File

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

View File

@ -0,0 +1 @@
# WebSocket app

View File

@ -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 통신'

View File

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

View File

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

View File

@ -0,0 +1,8 @@
from django.urls import path
app_name = 'websocket'
# WebSocket은 ASGI routing을 통해 처리되므로 HTTP URL은 없음
urlpatterns = [
# WebSocket 관련 HTTP 엔드포인트가 필요한 경우 여기에 추가
]

22
web/backend/manage.py Normal file
View File

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

View File

@ -0,0 +1 @@
# This file makes Python treat the directory as a package

View File

@ -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
)
),
})

View File

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

View File

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

View File

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

1
web/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

21962
web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
web/frontend/package.json Normal file
View File

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

View File

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

96
web/frontend/src/App.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

11
web/frontend/src/index.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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