# 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( endpoint: string, options: RequestInit = {} ): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', ...options.headers, }; if (this.token) { (headers as Record)['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(endpoint: string): Promise { return this.request(endpoint, { method: 'GET' }); } async post(endpoint: string, data?: unknown): Promise { return this.request(endpoint, { method: 'POST', body: data ? JSON.stringify(data) : undefined, }); } async put(endpoint: string, data?: unknown): Promise { return this.request(endpoint, { method: 'PUT', body: data ? JSON.stringify(data) : undefined, }); } async delete(endpoint: string): Promise { return this.request(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 ( ); } ``` **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 (

환영합니다{username ? `, ${username}` : ''}

); } ``` **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 ( {children} ); } ``` **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(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 (
Loading...
); } return (

대시보드

총 자산

₩0

총 수익률

+0.00%

포트폴리오

0개

리밸런싱 필요

0건

최근 활동

아직 활동 내역이 없습니다.

); } ``` **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 (

Galaxy-PO

{error && (
{error}
)}
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 />
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 />
); } ``` **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: 데이터 수집 기능 구현