galaxis-po/docs/plans/2026-02-02-phase1-foundation.md
zephyrdark 5c2f92438f docs: add Phase 1 foundation implementation plan
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>
2026-02-02 22:40:38 +09:00

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: 데이터 수집 기능 구현