Detailed step-by-step plan for: - Project structure initialization - Docker Compose configuration - Database models and migrations - Authentication API - Frontend base layout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1820 lines
42 KiB
Markdown
1820 lines
42 KiB
Markdown
# Phase 1: Foundation Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Set up the foundational project structure with FastAPI backend, Next.js frontend, PostgreSQL database, and Docker Compose orchestration.
|
|
|
|
**Architecture:** Monorepo structure with separate backend (FastAPI + Python 3.14) and frontend (Next.js 15 + Node 24) directories. PostgreSQL 15 for persistence. JWT-based authentication. All services containerized with Docker Compose.
|
|
|
|
**Tech Stack:** FastAPI, SQLAlchemy, Alembic, Next.js 15, React 19, TypeScript, Tailwind CSS, PostgreSQL 15, Docker
|
|
|
|
---
|
|
|
|
## Task 1: Project Structure Initialization
|
|
|
|
**Files:**
|
|
- Create: `backend/app/__init__.py`
|
|
- Create: `backend/app/main.py`
|
|
- Create: `backend/requirements.txt`
|
|
- Create: `frontend/package.json`
|
|
- Create: `.gitignore`
|
|
- Create: `README.md`
|
|
|
|
**Step 1: Create backend directory structure**
|
|
|
|
```bash
|
|
mkdir -p backend/app/{api,core,models,schemas,services}
|
|
mkdir -p backend/app/api
|
|
mkdir -p backend/jobs/collectors
|
|
mkdir -p backend/tests
|
|
```
|
|
|
|
**Step 2: Create backend/__init__.py files**
|
|
|
|
Create `backend/app/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
Create `backend/app/api/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
Create `backend/app/core/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
Create `backend/app/models/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
Create `backend/app/schemas/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
Create `backend/app/services/__init__.py`:
|
|
```python
|
|
```
|
|
|
|
**Step 3: Create backend/requirements.txt**
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
sqlalchemy==2.0.36
|
|
alembic==1.14.0
|
|
psycopg2-binary==2.9.10
|
|
pydantic==2.10.4
|
|
pydantic-settings==2.7.1
|
|
python-jose[cryptography]==3.3.0
|
|
passlib[bcrypt]==1.7.4
|
|
python-multipart==0.0.20
|
|
apscheduler==3.10.4
|
|
pykrx==1.0.45
|
|
requests==2.32.3
|
|
beautifulsoup4==4.12.3
|
|
lxml==5.3.0
|
|
pandas==2.2.3
|
|
numpy==2.2.1
|
|
httpx==0.28.1
|
|
pytest==8.3.4
|
|
pytest-asyncio==0.25.2
|
|
```
|
|
|
|
**Step 4: Create backend/app/main.py**
|
|
|
|
```python
|
|
"""
|
|
Galaxy-PO Backend API
|
|
"""
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
app = FastAPI(
|
|
title="Galaxy-PO API",
|
|
description="Quant Portfolio Management API",
|
|
version="0.1.0",
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:3000"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy"}
|
|
```
|
|
|
|
**Step 5: Create frontend with Next.js**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po
|
|
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
|
|
```
|
|
|
|
**Step 6: Create .gitignore**
|
|
|
|
```
|
|
# Python
|
|
__pycache__/
|
|
*.py[cod]
|
|
*$py.class
|
|
*.so
|
|
.Python
|
|
build/
|
|
develop-eggs/
|
|
dist/
|
|
downloads/
|
|
eggs/
|
|
.eggs/
|
|
lib/
|
|
lib64/
|
|
parts/
|
|
sdist/
|
|
var/
|
|
wheels/
|
|
*.egg-info/
|
|
.installed.cfg
|
|
*.egg
|
|
.venv/
|
|
venv/
|
|
ENV/
|
|
|
|
# Node
|
|
node_modules/
|
|
.next/
|
|
out/
|
|
npm-debug.log*
|
|
yarn-debug.log*
|
|
yarn-error.log*
|
|
|
|
# IDE
|
|
.idea/
|
|
.vscode/
|
|
*.swp
|
|
*.swo
|
|
|
|
# Environment
|
|
.env
|
|
.env.local
|
|
.env.*.local
|
|
|
|
# Docker
|
|
*.log
|
|
|
|
# OS
|
|
.DS_Store
|
|
Thumbs.db
|
|
|
|
# Database
|
|
*.db
|
|
*.sqlite3
|
|
|
|
# Test
|
|
.coverage
|
|
htmlcov/
|
|
.pytest_cache/
|
|
```
|
|
|
|
**Step 7: Create README.md**
|
|
|
|
```markdown
|
|
# Galaxy-PO
|
|
|
|
Integrated Quant Portfolio Management Application
|
|
|
|
## Tech Stack
|
|
|
|
- **Backend:** FastAPI, Python 3.14, SQLAlchemy, PostgreSQL
|
|
- **Frontend:** Next.js 15, React 19, TypeScript, Tailwind CSS
|
|
- **Infrastructure:** Docker, Docker Compose
|
|
|
|
## Development
|
|
|
|
### Prerequisites
|
|
|
|
- Docker & Docker Compose
|
|
- Python 3.14
|
|
- Node.js 24
|
|
|
|
### Quick Start
|
|
|
|
```bash
|
|
# Start all services
|
|
docker-compose up -d
|
|
|
|
# Backend only (development)
|
|
cd backend
|
|
python -m venv venv
|
|
source venv/bin/activate
|
|
pip install -r requirements.txt
|
|
uvicorn app.main:app --reload
|
|
|
|
# Frontend only (development)
|
|
cd frontend
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
galaxy-po/
|
|
├── backend/ # FastAPI backend
|
|
├── frontend/ # Next.js frontend
|
|
├── docker-compose.yml
|
|
└── docs/plans/ # Implementation plans
|
|
```
|
|
```
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: initialize project structure with backend and frontend scaffolding"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Docker Compose Configuration
|
|
|
|
**Files:**
|
|
- Create: `docker-compose.yml`
|
|
- Create: `backend/Dockerfile`
|
|
- Create: `frontend/Dockerfile`
|
|
- Create: `.env.example`
|
|
|
|
**Step 1: Create docker-compose.yml**
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
postgres:
|
|
image: postgres:15-alpine
|
|
container_name: galaxy-po-db
|
|
environment:
|
|
POSTGRES_USER: galaxy
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
|
|
POSTGRES_DB: galaxy_po
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
ports:
|
|
- "5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U galaxy -d galaxy_po"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
backend:
|
|
build:
|
|
context: ./backend
|
|
dockerfile: Dockerfile
|
|
container_name: galaxy-po-backend
|
|
environment:
|
|
DATABASE_URL: postgresql://galaxy:${DB_PASSWORD:-devpassword}@postgres:5432/galaxy_po
|
|
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-in-production}
|
|
KIS_APP_KEY: ${KIS_APP_KEY:-}
|
|
KIS_APP_SECRET: ${KIS_APP_SECRET:-}
|
|
DART_API_KEY: ${DART_API_KEY:-}
|
|
ports:
|
|
- "8000:8000"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
volumes:
|
|
- ./backend:/app
|
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
|
|
frontend:
|
|
build:
|
|
context: ./frontend
|
|
dockerfile: Dockerfile
|
|
container_name: galaxy-po-frontend
|
|
environment:
|
|
NEXT_PUBLIC_API_URL: http://localhost:8000
|
|
ports:
|
|
- "3000:3000"
|
|
depends_on:
|
|
- backend
|
|
volumes:
|
|
- ./frontend:/app
|
|
- /app/node_modules
|
|
- /app/.next
|
|
|
|
volumes:
|
|
postgres_data:
|
|
```
|
|
|
|
**Step 2: Create backend/Dockerfile**
|
|
|
|
```dockerfile
|
|
FROM python:3.14-slim
|
|
|
|
WORKDIR /app
|
|
|
|
# Install system dependencies
|
|
RUN apt-get update && apt-get install -y \
|
|
gcc \
|
|
libpq-dev \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Install Python dependencies
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
# Copy application code
|
|
COPY . .
|
|
|
|
# Expose port
|
|
EXPOSE 8000
|
|
|
|
# Run the application
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
```
|
|
|
|
**Step 3: Create frontend/Dockerfile**
|
|
|
|
```dockerfile
|
|
FROM node:24-alpine
|
|
|
|
WORKDIR /app
|
|
|
|
# Install dependencies
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
|
|
# Copy application code
|
|
COPY . .
|
|
|
|
# Expose port
|
|
EXPOSE 3000
|
|
|
|
# Development command
|
|
CMD ["npm", "run", "dev"]
|
|
```
|
|
|
|
**Step 4: Create .env.example**
|
|
|
|
```bash
|
|
# Database
|
|
DB_PASSWORD=your_secure_password_here
|
|
|
|
# JWT Authentication
|
|
JWT_SECRET=your_jwt_secret_key_here
|
|
|
|
# Korea Investment & Securities OpenAPI
|
|
KIS_APP_KEY=your_kis_app_key
|
|
KIS_APP_SECRET=your_kis_app_secret
|
|
KIS_ACCOUNT_NO=your_account_number
|
|
|
|
# DART OpenAPI (Financial Statements)
|
|
DART_API_KEY=your_dart_api_key
|
|
```
|
|
|
|
**Step 5: Verify Docker Compose syntax**
|
|
|
|
```bash
|
|
docker-compose config
|
|
```
|
|
|
|
Expected: Valid YAML output without errors
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add docker-compose.yml backend/Dockerfile frontend/Dockerfile .env.example
|
|
git commit -m "feat: add Docker Compose configuration for all services"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Database Configuration and Core Settings
|
|
|
|
**Files:**
|
|
- Create: `backend/app/core/config.py`
|
|
- Create: `backend/app/core/database.py`
|
|
- Create: `backend/app/core/security.py`
|
|
|
|
**Step 1: Create backend/app/core/config.py**
|
|
|
|
```python
|
|
"""
|
|
Application configuration using Pydantic Settings.
|
|
"""
|
|
from pydantic_settings import BaseSettings
|
|
from functools import lru_cache
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
# Application
|
|
app_name: str = "Galaxy-PO"
|
|
debug: bool = False
|
|
|
|
# Database
|
|
database_url: str = "postgresql://galaxy:devpassword@localhost:5432/galaxy_po"
|
|
|
|
# JWT
|
|
jwt_secret: str = "dev-jwt-secret-change-in-production"
|
|
jwt_algorithm: str = "HS256"
|
|
access_token_expire_minutes: int = 60 * 24 # 24 hours
|
|
|
|
# External APIs
|
|
kis_app_key: str = ""
|
|
kis_app_secret: str = ""
|
|
kis_account_no: str = ""
|
|
dart_api_key: str = ""
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
case_sensitive = False
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|
|
```
|
|
|
|
**Step 2: Create backend/app/core/database.py**
|
|
|
|
```python
|
|
"""
|
|
Database connection and session management.
|
|
"""
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
|
|
engine = create_engine(
|
|
settings.database_url,
|
|
pool_pre_ping=True,
|
|
pool_size=10,
|
|
max_overflow=20,
|
|
)
|
|
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
def get_db():
|
|
"""Dependency for getting database session."""
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
```
|
|
|
|
**Step 3: Create backend/app/core/security.py**
|
|
|
|
```python
|
|
"""
|
|
Security utilities for authentication.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
from jose import JWTError, jwt
|
|
from passlib.context import CryptContext
|
|
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
|
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
"""Verify a password against its hash."""
|
|
return pwd_context.verify(plain_password, hashed_password)
|
|
|
|
|
|
def get_password_hash(password: str) -> str:
|
|
"""Hash a password."""
|
|
return pwd_context.hash(password)
|
|
|
|
|
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
"""Create a JWT access token."""
|
|
to_encode = data.copy()
|
|
if expires_delta:
|
|
expire = datetime.now(timezone.utc) + expires_delta
|
|
else:
|
|
expire = datetime.now(timezone.utc) + timedelta(
|
|
minutes=settings.access_token_expire_minutes
|
|
)
|
|
to_encode.update({"exp": expire})
|
|
encoded_jwt = jwt.encode(
|
|
to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm
|
|
)
|
|
return encoded_jwt
|
|
|
|
|
|
def decode_access_token(token: str) -> Optional[dict]:
|
|
"""Decode and verify a JWT access token."""
|
|
try:
|
|
payload = jwt.decode(
|
|
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
|
)
|
|
return payload
|
|
except JWTError:
|
|
return None
|
|
```
|
|
|
|
**Step 4: Update backend/app/core/__init__.py**
|
|
|
|
```python
|
|
from app.core.config import get_settings, Settings
|
|
from app.core.database import get_db, Base, engine, SessionLocal
|
|
from app.core.security import (
|
|
verify_password,
|
|
get_password_hash,
|
|
create_access_token,
|
|
decode_access_token,
|
|
)
|
|
|
|
__all__ = [
|
|
"get_settings",
|
|
"Settings",
|
|
"get_db",
|
|
"Base",
|
|
"engine",
|
|
"SessionLocal",
|
|
"verify_password",
|
|
"get_password_hash",
|
|
"create_access_token",
|
|
"decode_access_token",
|
|
]
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/core/
|
|
git commit -m "feat: add core configuration, database, and security modules"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Database Models
|
|
|
|
**Files:**
|
|
- Create: `backend/app/models/user.py`
|
|
- Create: `backend/app/models/portfolio.py`
|
|
- Create: `backend/app/models/stock.py`
|
|
- Update: `backend/app/models/__init__.py`
|
|
|
|
**Step 1: Create backend/app/models/user.py**
|
|
|
|
```python
|
|
"""
|
|
User model for authentication.
|
|
"""
|
|
from datetime import datetime
|
|
from sqlalchemy import Column, Integer, String, DateTime
|
|
from app.core.database import Base
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
|
email = Column(String(100), unique=True, index=True, nullable=False)
|
|
hashed_password = Column(String(255), nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
```
|
|
|
|
**Step 2: Create backend/app/models/portfolio.py**
|
|
|
|
```python
|
|
"""
|
|
Portfolio related models.
|
|
"""
|
|
from datetime import datetime
|
|
from sqlalchemy import (
|
|
Column, Integer, String, Numeric, DateTime, Date,
|
|
ForeignKey, Text, Enum as SQLEnum
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
import enum
|
|
|
|
from app.core.database import Base
|
|
|
|
|
|
class PortfolioType(str, enum.Enum):
|
|
PENSION = "pension"
|
|
GENERAL = "general"
|
|
|
|
|
|
class TransactionType(str, enum.Enum):
|
|
BUY = "buy"
|
|
SELL = "sell"
|
|
|
|
|
|
class Portfolio(Base):
|
|
__tablename__ = "portfolios"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
name = Column(String(100), nullable=False)
|
|
portfolio_type = Column(SQLEnum(PortfolioType), default=PortfolioType.GENERAL)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
# Relationships
|
|
targets = relationship("Target", back_populates="portfolio", cascade="all, delete-orphan")
|
|
holdings = relationship("Holding", back_populates="portfolio", cascade="all, delete-orphan")
|
|
transactions = relationship("Transaction", back_populates="portfolio", cascade="all, delete-orphan")
|
|
snapshots = relationship("PortfolioSnapshot", back_populates="portfolio", cascade="all, delete-orphan")
|
|
|
|
|
|
class Target(Base):
|
|
__tablename__ = "targets"
|
|
|
|
portfolio_id = Column(Integer, ForeignKey("portfolios.id"), primary_key=True)
|
|
ticker = Column(String(20), primary_key=True)
|
|
target_ratio = Column(Numeric(5, 2), nullable=False) # e.g., 25.00%
|
|
|
|
portfolio = relationship("Portfolio", back_populates="targets")
|
|
|
|
|
|
class Holding(Base):
|
|
__tablename__ = "holdings"
|
|
|
|
portfolio_id = Column(Integer, ForeignKey("portfolios.id"), primary_key=True)
|
|
ticker = Column(String(20), primary_key=True)
|
|
quantity = Column(Integer, nullable=False, default=0)
|
|
avg_price = Column(Numeric(12, 2), nullable=False, default=0)
|
|
|
|
portfolio = relationship("Portfolio", back_populates="holdings")
|
|
|
|
|
|
class Transaction(Base):
|
|
__tablename__ = "transactions"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
portfolio_id = Column(Integer, ForeignKey("portfolios.id"), nullable=False)
|
|
ticker = Column(String(20), nullable=False)
|
|
tx_type = Column(SQLEnum(TransactionType), nullable=False)
|
|
quantity = Column(Integer, nullable=False)
|
|
price = Column(Numeric(12, 2), nullable=False)
|
|
executed_at = Column(DateTime, nullable=False)
|
|
memo = Column(Text, nullable=True)
|
|
|
|
portfolio = relationship("Portfolio", back_populates="transactions")
|
|
|
|
|
|
class PortfolioSnapshot(Base):
|
|
__tablename__ = "portfolio_snapshots"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
portfolio_id = Column(Integer, ForeignKey("portfolios.id"), nullable=False)
|
|
total_value = Column(Numeric(15, 2), nullable=False)
|
|
snapshot_date = Column(Date, nullable=False)
|
|
|
|
portfolio = relationship("Portfolio", back_populates="snapshots")
|
|
holdings = relationship("SnapshotHolding", back_populates="snapshot", cascade="all, delete-orphan")
|
|
|
|
|
|
class SnapshotHolding(Base):
|
|
__tablename__ = "snapshot_holdings"
|
|
|
|
snapshot_id = Column(Integer, ForeignKey("portfolio_snapshots.id"), primary_key=True)
|
|
ticker = Column(String(20), primary_key=True)
|
|
quantity = Column(Integer, nullable=False)
|
|
price = Column(Numeric(12, 2), nullable=False)
|
|
value = Column(Numeric(15, 2), nullable=False)
|
|
current_ratio = Column(Numeric(5, 2), nullable=False)
|
|
|
|
snapshot = relationship("PortfolioSnapshot", back_populates="holdings")
|
|
```
|
|
|
|
**Step 3: Create backend/app/models/stock.py**
|
|
|
|
```python
|
|
"""
|
|
Stock and market data models.
|
|
"""
|
|
from datetime import datetime
|
|
from sqlalchemy import (
|
|
Column, Integer, String, Numeric, DateTime, Date,
|
|
BigInteger, Text, Enum as SQLEnum
|
|
)
|
|
import enum
|
|
|
|
from app.core.database import Base
|
|
|
|
|
|
class StockType(str, enum.Enum):
|
|
COMMON = "common"
|
|
SPAC = "spac"
|
|
PREFERRED = "preferred"
|
|
REIT = "reit"
|
|
OTHER = "other"
|
|
|
|
|
|
class ReportType(str, enum.Enum):
|
|
ANNUAL = "annual"
|
|
QUARTERLY = "quarterly"
|
|
|
|
|
|
class AssetClass(str, enum.Enum):
|
|
EQUITY = "equity"
|
|
BOND = "bond"
|
|
GOLD = "gold"
|
|
MIXED = "mixed"
|
|
|
|
|
|
class Stock(Base):
|
|
__tablename__ = "stocks"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
name = Column(String(100), nullable=False)
|
|
market = Column(String(20), nullable=False) # KOSPI, KOSDAQ
|
|
close_price = Column(Numeric(12, 2), nullable=True)
|
|
market_cap = Column(BigInteger, nullable=True)
|
|
eps = Column(Numeric(12, 2), nullable=True)
|
|
forward_eps = Column(Numeric(12, 2), nullable=True)
|
|
bps = Column(Numeric(12, 2), nullable=True)
|
|
dividend_per_share = Column(Numeric(12, 2), nullable=True)
|
|
stock_type = Column(SQLEnum(StockType), default=StockType.COMMON)
|
|
base_date = Column(Date, nullable=False)
|
|
|
|
|
|
class Sector(Base):
|
|
__tablename__ = "sectors"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
sector_code = Column(String(10), nullable=False)
|
|
company_name = Column(String(100), nullable=False)
|
|
sector_name = Column(String(50), nullable=False)
|
|
base_date = Column(Date, nullable=False)
|
|
|
|
|
|
class Valuation(Base):
|
|
__tablename__ = "valuations"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
base_date = Column(Date, primary_key=True)
|
|
per = Column(Numeric(10, 2), nullable=True)
|
|
pbr = Column(Numeric(10, 2), nullable=True)
|
|
psr = Column(Numeric(10, 2), nullable=True)
|
|
pcr = Column(Numeric(10, 2), nullable=True)
|
|
dividend_yield = Column(Numeric(6, 2), nullable=True)
|
|
|
|
|
|
class Price(Base):
|
|
__tablename__ = "prices"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
date = Column(Date, primary_key=True)
|
|
open = Column(Numeric(12, 2), nullable=False)
|
|
high = Column(Numeric(12, 2), nullable=False)
|
|
low = Column(Numeric(12, 2), nullable=False)
|
|
close = Column(Numeric(12, 2), nullable=False)
|
|
volume = Column(BigInteger, nullable=False)
|
|
|
|
|
|
class Financial(Base):
|
|
__tablename__ = "financials"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
base_date = Column(Date, primary_key=True)
|
|
report_type = Column(SQLEnum(ReportType), primary_key=True)
|
|
account = Column(String(50), primary_key=True)
|
|
value = Column(Numeric(20, 2), nullable=True)
|
|
|
|
|
|
class ETF(Base):
|
|
__tablename__ = "etfs"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
name = Column(String(100), nullable=False)
|
|
asset_class = Column(SQLEnum(AssetClass), nullable=False)
|
|
market = Column(String(20), nullable=False)
|
|
expense_ratio = Column(Numeric(5, 4), nullable=True)
|
|
|
|
|
|
class ETFPrice(Base):
|
|
__tablename__ = "etf_prices"
|
|
|
|
ticker = Column(String(20), primary_key=True)
|
|
date = Column(Date, primary_key=True)
|
|
close = Column(Numeric(12, 2), nullable=False)
|
|
nav = Column(Numeric(12, 2), nullable=True)
|
|
volume = Column(BigInteger, nullable=True)
|
|
|
|
|
|
class JobLog(Base):
|
|
__tablename__ = "job_logs"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
job_name = Column(String(50), nullable=False)
|
|
status = Column(String(20), nullable=False) # running, success, failed
|
|
started_at = Column(DateTime, default=datetime.utcnow)
|
|
finished_at = Column(DateTime, nullable=True)
|
|
records_count = Column(Integer, nullable=True)
|
|
error_msg = Column(Text, nullable=True)
|
|
```
|
|
|
|
**Step 4: Update backend/app/models/__init__.py**
|
|
|
|
```python
|
|
from app.models.user import User
|
|
from app.models.portfolio import (
|
|
Portfolio,
|
|
PortfolioType,
|
|
Target,
|
|
Holding,
|
|
Transaction,
|
|
TransactionType,
|
|
PortfolioSnapshot,
|
|
SnapshotHolding,
|
|
)
|
|
from app.models.stock import (
|
|
Stock,
|
|
StockType,
|
|
Sector,
|
|
Valuation,
|
|
Price,
|
|
Financial,
|
|
ReportType,
|
|
ETF,
|
|
ETFPrice,
|
|
AssetClass,
|
|
JobLog,
|
|
)
|
|
|
|
__all__ = [
|
|
"User",
|
|
"Portfolio",
|
|
"PortfolioType",
|
|
"Target",
|
|
"Holding",
|
|
"Transaction",
|
|
"TransactionType",
|
|
"PortfolioSnapshot",
|
|
"SnapshotHolding",
|
|
"Stock",
|
|
"StockType",
|
|
"Sector",
|
|
"Valuation",
|
|
"Price",
|
|
"Financial",
|
|
"ReportType",
|
|
"ETF",
|
|
"ETFPrice",
|
|
"AssetClass",
|
|
"JobLog",
|
|
]
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/
|
|
git commit -m "feat: add database models for users, portfolios, and market data"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Alembic Migration Setup
|
|
|
|
**Files:**
|
|
- Create: `backend/alembic.ini`
|
|
- Create: `backend/alembic/env.py`
|
|
- Create: `backend/alembic/script.py.mako`
|
|
- Create: `backend/alembic/versions/` (directory)
|
|
|
|
**Step 1: Initialize Alembic**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po/backend
|
|
pip install alembic
|
|
alembic init alembic
|
|
```
|
|
|
|
**Step 2: Update backend/alembic.ini**
|
|
|
|
Replace the sqlalchemy.url line:
|
|
|
|
```ini
|
|
# Change this line:
|
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
|
|
# To:
|
|
sqlalchemy.url = postgresql://galaxy:devpassword@localhost:5432/galaxy_po
|
|
```
|
|
|
|
**Step 3: Update backend/alembic/env.py**
|
|
|
|
```python
|
|
import os
|
|
import sys
|
|
from logging.config import fileConfig
|
|
|
|
from sqlalchemy import engine_from_config
|
|
from sqlalchemy import pool
|
|
|
|
from alembic import context
|
|
|
|
# Add backend to path
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from app.core.database import Base
|
|
from app.models import * # noqa: Import all models
|
|
|
|
# this is the Alembic Config object
|
|
config = context.config
|
|
|
|
# Override sqlalchemy.url with environment variable if available
|
|
database_url = os.getenv("DATABASE_URL")
|
|
if database_url:
|
|
config.set_main_option("sqlalchemy.url", database_url)
|
|
|
|
# Interpret the config file for Python logging.
|
|
if config.config_file_name is not None:
|
|
fileConfig(config.config_file_name)
|
|
|
|
# Model's MetaData object for 'autogenerate' support
|
|
target_metadata = Base.metadata
|
|
|
|
|
|
def run_migrations_offline() -> None:
|
|
"""Run migrations in 'offline' mode."""
|
|
url = config.get_main_option("sqlalchemy.url")
|
|
context.configure(
|
|
url=url,
|
|
target_metadata=target_metadata,
|
|
literal_binds=True,
|
|
dialect_opts={"paramstyle": "named"},
|
|
)
|
|
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
def run_migrations_online() -> None:
|
|
"""Run migrations in 'online' mode."""
|
|
connectable = engine_from_config(
|
|
config.get_section(config.config_ini_section, {}),
|
|
prefix="sqlalchemy.",
|
|
poolclass=pool.NullPool,
|
|
)
|
|
|
|
with connectable.connect() as connection:
|
|
context.configure(
|
|
connection=connection, target_metadata=target_metadata
|
|
)
|
|
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
if context.is_offline_mode():
|
|
run_migrations_offline()
|
|
else:
|
|
run_migrations_online()
|
|
```
|
|
|
|
**Step 4: Create initial migration**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po/backend
|
|
alembic revision --autogenerate -m "Initial tables"
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/alembic.ini backend/alembic/
|
|
git commit -m "feat: add Alembic migration setup with initial tables"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Authentication API
|
|
|
|
**Files:**
|
|
- Create: `backend/app/schemas/auth.py`
|
|
- Create: `backend/app/schemas/user.py`
|
|
- Create: `backend/app/api/auth.py`
|
|
- Create: `backend/app/api/deps.py`
|
|
- Update: `backend/app/main.py`
|
|
|
|
**Step 1: Create backend/app/schemas/user.py**
|
|
|
|
```python
|
|
"""
|
|
User schemas for request/response validation.
|
|
"""
|
|
from datetime import datetime
|
|
from pydantic import BaseModel, EmailStr
|
|
|
|
|
|
class UserBase(BaseModel):
|
|
username: str
|
|
email: EmailStr
|
|
|
|
|
|
class UserCreate(UserBase):
|
|
password: str
|
|
|
|
|
|
class UserResponse(UserBase):
|
|
id: int
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
```
|
|
|
|
**Step 2: Create backend/app/schemas/auth.py**
|
|
|
|
```python
|
|
"""
|
|
Authentication schemas.
|
|
"""
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Token(BaseModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
|
|
|
|
class TokenPayload(BaseModel):
|
|
sub: str | None = None
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
```
|
|
|
|
**Step 3: Update backend/app/schemas/__init__.py**
|
|
|
|
```python
|
|
from app.schemas.user import UserBase, UserCreate, UserResponse
|
|
from app.schemas.auth import Token, TokenPayload, LoginRequest
|
|
|
|
__all__ = [
|
|
"UserBase",
|
|
"UserCreate",
|
|
"UserResponse",
|
|
"Token",
|
|
"TokenPayload",
|
|
"LoginRequest",
|
|
]
|
|
```
|
|
|
|
**Step 4: Create backend/app/api/deps.py**
|
|
|
|
```python
|
|
"""
|
|
API dependencies.
|
|
"""
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import decode_access_token
|
|
from app.models.user import User
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
|
|
|
|
|
async def get_current_user(
|
|
db: Annotated[Session, Depends(get_db)],
|
|
token: Annotated[str, Depends(oauth2_scheme)],
|
|
) -> User:
|
|
"""Get the current authenticated user."""
|
|
credentials_exception = HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
payload = decode_access_token(token)
|
|
if payload is None:
|
|
raise credentials_exception
|
|
|
|
username: str = payload.get("sub")
|
|
if username is None:
|
|
raise credentials_exception
|
|
|
|
user = db.query(User).filter(User.username == username).first()
|
|
if user is None:
|
|
raise credentials_exception
|
|
|
|
return user
|
|
|
|
|
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
```
|
|
|
|
**Step 5: Create backend/app/api/auth.py**
|
|
|
|
```python
|
|
"""
|
|
Authentication API endpoints.
|
|
"""
|
|
from datetime import timedelta
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import (
|
|
verify_password,
|
|
get_password_hash,
|
|
create_access_token,
|
|
)
|
|
from app.core.config import get_settings
|
|
from app.models.user import User
|
|
from app.schemas import Token, UserCreate, UserResponse, LoginRequest
|
|
from app.api.deps import CurrentUser
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
settings = get_settings()
|
|
|
|
|
|
@router.post("/login", response_model=Token)
|
|
async def login(
|
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
db: Annotated[Session, Depends(get_db)],
|
|
):
|
|
"""Login and get access token."""
|
|
user = db.query(User).filter(User.username == form_data.username).first()
|
|
|
|
if not user or not verify_password(form_data.password, user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect username or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
access_token = create_access_token(
|
|
data={"sub": user.username},
|
|
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
|
|
)
|
|
|
|
return Token(access_token=access_token)
|
|
|
|
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def register(
|
|
user_data: UserCreate,
|
|
db: Annotated[Session, Depends(get_db)],
|
|
):
|
|
"""Register a new user."""
|
|
# Check if username exists
|
|
if db.query(User).filter(User.username == user_data.username).first():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Username already registered",
|
|
)
|
|
|
|
# Check if email exists
|
|
if db.query(User).filter(User.email == user_data.email).first():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email already registered",
|
|
)
|
|
|
|
# Create user
|
|
user = User(
|
|
username=user_data.username,
|
|
email=user_data.email,
|
|
hashed_password=get_password_hash(user_data.password),
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
return user
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user_info(current_user: CurrentUser):
|
|
"""Get current user information."""
|
|
return current_user
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout():
|
|
"""Logout (client should discard token)."""
|
|
return {"message": "Successfully logged out"}
|
|
```
|
|
|
|
**Step 6: Update backend/app/api/__init__.py**
|
|
|
|
```python
|
|
from app.api.auth import router as auth_router
|
|
|
|
__all__ = ["auth_router"]
|
|
```
|
|
|
|
**Step 7: Update backend/app/main.py**
|
|
|
|
```python
|
|
"""
|
|
Galaxy-PO Backend API
|
|
"""
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from app.api import auth_router
|
|
|
|
app = FastAPI(
|
|
title="Galaxy-PO API",
|
|
description="Quant Portfolio Management API",
|
|
version="0.1.0",
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:3000"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Include routers
|
|
app.include_router(auth_router)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy"}
|
|
```
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add backend/app/schemas/ backend/app/api/ backend/app/main.py
|
|
git commit -m "feat: add authentication API with login, register, and user endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend Base Layout
|
|
|
|
**Files:**
|
|
- Update: `frontend/src/app/layout.tsx`
|
|
- Update: `frontend/src/app/page.tsx`
|
|
- Create: `frontend/src/app/globals.css` (update)
|
|
- Create: `frontend/src/components/layout/Sidebar.tsx`
|
|
- Create: `frontend/src/components/layout/Header.tsx`
|
|
- Create: `frontend/src/lib/api.ts`
|
|
|
|
**Step 1: Create frontend/src/lib/api.ts**
|
|
|
|
```typescript
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
|
|
class ApiClient {
|
|
private baseUrl: string;
|
|
private token: string | null = null;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.baseUrl = baseUrl;
|
|
if (typeof window !== 'undefined') {
|
|
this.token = localStorage.getItem('token');
|
|
}
|
|
}
|
|
|
|
setToken(token: string) {
|
|
this.token = token;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('token', token);
|
|
}
|
|
}
|
|
|
|
clearToken() {
|
|
this.token = null;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem('token');
|
|
}
|
|
}
|
|
|
|
private async request<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
};
|
|
|
|
if (this.token) {
|
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${this.token}`;
|
|
}
|
|
|
|
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || 'API request failed');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async get<T>(endpoint: string): Promise<T> {
|
|
return this.request<T>(endpoint, { method: 'GET' });
|
|
}
|
|
|
|
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'POST',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PUT',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
async delete<T>(endpoint: string): Promise<T> {
|
|
return this.request<T>(endpoint, { method: 'DELETE' });
|
|
}
|
|
|
|
// Auth methods
|
|
async login(username: string, password: string) {
|
|
const formData = new URLSearchParams();
|
|
formData.append('username', username);
|
|
formData.append('password', password);
|
|
|
|
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || 'Login failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.setToken(data.access_token);
|
|
return data;
|
|
}
|
|
|
|
logout() {
|
|
this.clearToken();
|
|
}
|
|
|
|
async getCurrentUser() {
|
|
return this.get('/api/auth/me');
|
|
}
|
|
}
|
|
|
|
export const api = new ApiClient(API_URL);
|
|
```
|
|
|
|
**Step 2: Create frontend/src/components/layout/Sidebar.tsx**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
|
|
const menuItems = [
|
|
{ href: '/', label: '대시보드', icon: '📊' },
|
|
{ href: '/portfolio', label: '포트폴리오', icon: '💼' },
|
|
{ href: '/strategy', label: '퀀트 전략', icon: '📈' },
|
|
{ href: '/backtest', label: '백테스트', icon: '🔬' },
|
|
{ href: '/market', label: '시세 조회', icon: '💹' },
|
|
{ href: '/admin/data', label: '데이터 관리', icon: '⚙️' },
|
|
];
|
|
|
|
export default function Sidebar() {
|
|
const pathname = usePathname();
|
|
|
|
return (
|
|
<aside className="w-64 bg-gray-900 text-white min-h-screen p-4">
|
|
<div className="mb-8">
|
|
<h1 className="text-xl font-bold">Galaxy-PO</h1>
|
|
<p className="text-gray-400 text-sm">Quant Portfolio Manager</p>
|
|
</div>
|
|
|
|
<nav>
|
|
<ul className="space-y-2">
|
|
{menuItems.map((item) => {
|
|
const isActive = pathname === item.href ||
|
|
(item.href !== '/' && pathname.startsWith(item.href));
|
|
|
|
return (
|
|
<li key={item.href}>
|
|
<Link
|
|
href={item.href}
|
|
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
|
|
isActive
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-300 hover:bg-gray-800'
|
|
}`}
|
|
>
|
|
<span>{item.icon}</span>
|
|
<span>{item.label}</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Create frontend/src/components/layout/Header.tsx**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useRouter } from 'next/navigation';
|
|
import { api } from '@/lib/api';
|
|
|
|
interface HeaderProps {
|
|
username?: string;
|
|
}
|
|
|
|
export default function Header({ username }: HeaderProps) {
|
|
const router = useRouter();
|
|
|
|
const handleLogout = () => {
|
|
api.logout();
|
|
router.push('/login');
|
|
};
|
|
|
|
return (
|
|
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-800">
|
|
환영합니다{username ? `, ${username}` : ''}
|
|
</h2>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={handleLogout}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 4: Update frontend/src/app/globals.css**
|
|
|
|
```css
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
:root {
|
|
--foreground-rgb: 0, 0, 0;
|
|
--background-rgb: 249, 250, 251;
|
|
}
|
|
|
|
body {
|
|
color: rgb(var(--foreground-rgb));
|
|
background: rgb(var(--background-rgb));
|
|
}
|
|
```
|
|
|
|
**Step 5: Update frontend/src/app/layout.tsx**
|
|
|
|
```typescript
|
|
import type { Metadata } from 'next';
|
|
import { Inter } from 'next/font/google';
|
|
import './globals.css';
|
|
|
|
const inter = Inter({ subsets: ['latin'] });
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Galaxy-PO',
|
|
description: 'Quant Portfolio Management Application',
|
|
};
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: Readonly<{
|
|
children: React.ReactNode;
|
|
}>) {
|
|
return (
|
|
<html lang="ko">
|
|
<body className={inter.className}>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 6: Update frontend/src/app/page.tsx**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Sidebar from '@/components/layout/Sidebar';
|
|
import Header from '@/components/layout/Header';
|
|
import { api } from '@/lib/api';
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
email: string;
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const router = useRouter();
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const checkAuth = async () => {
|
|
try {
|
|
const userData = await api.getCurrentUser() as User;
|
|
setUser(userData);
|
|
} catch {
|
|
router.push('/login');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
checkAuth();
|
|
}, [router]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-gray-500">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen">
|
|
<Sidebar />
|
|
<div className="flex-1">
|
|
<Header username={user?.username} />
|
|
<main className="p-6">
|
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">대시보드</h1>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-gray-500 text-sm mb-1">총 자산</h3>
|
|
<p className="text-2xl font-bold text-gray-800">₩0</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-gray-500 text-sm mb-1">총 수익률</h3>
|
|
<p className="text-2xl font-bold text-green-600">+0.00%</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-gray-500 text-sm mb-1">포트폴리오</h3>
|
|
<p className="text-2xl font-bold text-gray-800">0개</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-gray-500 text-sm mb-1">리밸런싱 필요</h3>
|
|
<p className="text-2xl font-bold text-orange-600">0건</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">최근 활동</h2>
|
|
<p className="text-gray-500">아직 활동 내역이 없습니다.</p>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 7: Create frontend/src/app/login/page.tsx**
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { api } from '@/lib/api';
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
await api.login(username, password);
|
|
router.push('/');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
|
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
|
<h1 className="text-2xl font-bold text-center text-gray-800 mb-8">
|
|
Galaxy-PO
|
|
</h1>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{error && (
|
|
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="username"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
사용자명
|
|
</label>
|
|
<input
|
|
id="username"
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="password"
|
|
className="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
비밀번호
|
|
</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
|
|
>
|
|
{loading ? '로그인 중...' : '로그인'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/
|
|
git commit -m "feat: add frontend base layout with sidebar, header, and login page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Verify Full Stack Integration
|
|
|
|
**Step 1: Start PostgreSQL**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po
|
|
docker-compose up -d postgres
|
|
```
|
|
|
|
Expected: PostgreSQL container running
|
|
|
|
**Step 2: Run migrations**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po/backend
|
|
pip install -r requirements.txt
|
|
alembic upgrade head
|
|
```
|
|
|
|
Expected: All migrations applied successfully
|
|
|
|
**Step 3: Start backend**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po/backend
|
|
uvicorn app.main:app --reload
|
|
```
|
|
|
|
Expected: Server running at http://localhost:8000
|
|
|
|
**Step 4: Test health endpoint**
|
|
|
|
```bash
|
|
curl http://localhost:8000/health
|
|
```
|
|
|
|
Expected: `{"status":"healthy"}`
|
|
|
|
**Step 5: Test user registration**
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/api/auth/register \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"testuser","email":"test@example.com","password":"testpass123"}'
|
|
```
|
|
|
|
Expected: User created with id, username, email, created_at
|
|
|
|
**Step 6: Test login**
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8000/api/auth/login \
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
-d "username=testuser&password=testpass123"
|
|
```
|
|
|
|
Expected: `{"access_token":"...","token_type":"bearer"}`
|
|
|
|
**Step 7: Start frontend (in new terminal)**
|
|
|
|
```bash
|
|
cd /home/zephyrdark/workspace/quant/galaxy-po/frontend
|
|
npm install
|
|
npm run dev
|
|
```
|
|
|
|
Expected: Frontend running at http://localhost:3000
|
|
|
|
**Step 8: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete Phase 1 foundation setup"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 1 완료 시 구현된 기능:
|
|
- FastAPI 백엔드 기본 구조
|
|
- PostgreSQL 데이터베이스 연결
|
|
- 모든 데이터베이스 모델 (users, portfolios, stocks, etc.)
|
|
- Alembic 마이그레이션 설정
|
|
- JWT 인증 (login, register, me)
|
|
- Next.js 프론트엔드 기본 구조
|
|
- 로그인 페이지
|
|
- 대시보드 레이아웃 (사이드바, 헤더)
|
|
- Docker Compose 설정
|
|
|
|
다음 Phase: 데이터 수집 기능 구현
|