galaxis-po/docs/plans/2026-02-03-phase4-quant-strategy.md

65 KiB

Phase 4: Quant Strategy Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement quant strategy engine with multi-factor, quality, and value-momentum strategies, plus stock screening and market data APIs.

Architecture: Factor-based stock ranking system. FactorCalculator computes value/quality/momentum factors from DB data. Strategy services combine factors with configurable weights. Screener filters universe and ranks by strategy scores.

Tech Stack: FastAPI, SQLAlchemy, Pydantic, pandas, NumPy, Next.js, React, TypeScript


Task 1: Strategy Pydantic Schemas

Files:

  • Create: backend/app/schemas/strategy.py
  • Update: backend/app/schemas/__init__.py

Step 1: Create strategy schemas

"""
Quant strategy related Pydantic schemas.
"""
from datetime import date
from decimal import Decimal
from typing import Optional, List, Dict

from pydantic import BaseModel, Field


class FactorWeights(BaseModel):
    """Factor weights for multi-factor strategy."""
    value: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
    quality: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
    momentum: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)
    low_vol: Decimal = Field(default=Decimal("0.25"), ge=0, le=1)


class UniverseFilter(BaseModel):
    """Stock universe filtering criteria."""
    markets: List[str] = ["KOSPI", "KOSDAQ"]
    min_market_cap: Optional[int] = None  # in 억원
    max_market_cap: Optional[int] = None
    exclude_stock_types: List[str] = ["spac", "preferred", "reit"]
    exclude_sectors: List[str] = []


class StrategyRequest(BaseModel):
    """Base request for running a strategy."""
    universe: UniverseFilter = UniverseFilter()
    top_n: int = Field(default=30, ge=1, le=100)
    base_date: Optional[date] = None


class MultiFactorRequest(StrategyRequest):
    """Multi-factor strategy request."""
    weights: FactorWeights = FactorWeights()


class QualityRequest(StrategyRequest):
    """Super Quality strategy request."""
    min_fscore: int = Field(default=7, ge=0, le=9)


class ValueMomentumRequest(StrategyRequest):
    """Value-Momentum strategy request."""
    value_weight: Decimal = Field(default=Decimal("0.5"), ge=0, le=1)
    momentum_weight: Decimal = Field(default=Decimal("0.5"), ge=0, le=1)


class StockFactor(BaseModel):
    """Factor scores for a single stock."""
    ticker: str
    name: str
    market: str
    sector_name: Optional[str] = None
    market_cap: Optional[int] = None
    close_price: Optional[Decimal] = None

    # Raw metrics
    per: Optional[Decimal] = None
    pbr: Optional[Decimal] = None
    psr: Optional[Decimal] = None
    pcr: Optional[Decimal] = None
    dividend_yield: Optional[Decimal] = None
    roe: Optional[Decimal] = None

    # Factor scores (z-scores)
    value_score: Optional[Decimal] = None
    quality_score: Optional[Decimal] = None
    momentum_score: Optional[Decimal] = None

    # Composite
    total_score: Optional[Decimal] = None
    rank: Optional[int] = None
    fscore: Optional[int] = None


class StrategyResult(BaseModel):
    """Result from running a strategy."""
    strategy_name: str
    base_date: date
    universe_count: int
    result_count: int
    stocks: List[StockFactor]


class StockInfo(BaseModel):
    """Detailed stock information."""
    ticker: str
    name: str
    market: str
    sector_name: Optional[str] = None
    stock_type: str
    close_price: Optional[Decimal] = None
    market_cap: Optional[int] = None

    # Valuation
    per: Optional[Decimal] = None
    pbr: Optional[Decimal] = None
    psr: Optional[Decimal] = None
    pcr: Optional[Decimal] = None
    dividend_yield: Optional[Decimal] = None

    # Per-share data
    eps: Optional[Decimal] = None
    bps: Optional[Decimal] = None

    base_date: Optional[date] = None

    class Config:
        from_attributes = True


class StockSearchResult(BaseModel):
    """Stock search result."""
    ticker: str
    name: str
    market: str


class PriceData(BaseModel):
    """Price data point."""
    date: date
    open: Decimal
    high: Decimal
    low: Decimal
    close: Decimal
    volume: int

    class Config:
        from_attributes = True

Step 2: Update schemas init.py

Add to imports and all:

from app.schemas.strategy import (
    FactorWeights, UniverseFilter,
    StrategyRequest, MultiFactorRequest, QualityRequest, ValueMomentumRequest,
    StockFactor, StrategyResult,
    StockInfo, StockSearchResult, PriceData,
)

Step 3: Commit

git add backend/app/schemas/
git commit -m "feat: add quant strategy Pydantic schemas"

Task 2: Factor Calculator Service

Files:

  • Create: backend/app/services/factor_calculator.py

Step 1: Create factor calculator

"""
Factor calculation service for quant strategies.
"""
from decimal import Decimal
from typing import Dict, List, Optional
from datetime import date, timedelta

import pandas as pd
from sqlalchemy.orm import Session
from sqlalchemy import func

from app.models.stock import Stock, Valuation, Price, Financial, Sector


class FactorCalculator:
    """Calculates factor scores for stocks."""

    def __init__(self, db: Session):
        self.db = db

    def get_universe(
        self,
        markets: List[str] = None,
        min_market_cap: int = None,
        max_market_cap: int = None,
        exclude_stock_types: List[str] = None,
        exclude_sectors: List[str] = None,
    ) -> List[Stock]:
        """Get filtered stock universe."""
        query = self.db.query(Stock)

        if markets:
            query = query.filter(Stock.market.in_(markets))
        if min_market_cap:
            # market_cap is in won, min_market_cap is in 억원
            query = query.filter(Stock.market_cap >= min_market_cap * 100_000_000)
        if max_market_cap:
            query = query.filter(Stock.market_cap <= max_market_cap * 100_000_000)
        if exclude_stock_types:
            query = query.filter(~Stock.stock_type.in_(exclude_stock_types))

        stocks = query.all()

        # Filter by sector if needed
        if exclude_sectors:
            sector_tickers = (
                self.db.query(Sector.ticker)
                .filter(Sector.sector_name.in_(exclude_sectors))
                .all()
            )
            excluded = {t[0] for t in sector_tickers}
            stocks = [s for s in stocks if s.ticker not in excluded]

        return stocks

    def get_valuations(self, tickers: List[str], base_date: date = None) -> Dict[str, Valuation]:
        """Get latest valuations for tickers."""
        if base_date:
            valuations = (
                self.db.query(Valuation)
                .filter(Valuation.ticker.in_(tickers))
                .filter(Valuation.base_date <= base_date)
                .order_by(Valuation.base_date.desc())
                .all()
            )
        else:
            valuations = (
                self.db.query(Valuation)
                .filter(Valuation.ticker.in_(tickers))
                .all()
            )

        # Get latest per ticker
        result = {}
        for v in valuations:
            if v.ticker not in result:
                result[v.ticker] = v
        return result

    def get_sectors(self, tickers: List[str]) -> Dict[str, str]:
        """Get sector names for tickers."""
        sectors = (
            self.db.query(Sector)
            .filter(Sector.ticker.in_(tickers))
            .all()
        )
        return {s.ticker: s.sector_name for s in sectors}

    def calculate_momentum(
        self,
        tickers: List[str],
        base_date: date = None,
        months: int = 12,
        skip_recent: int = 1,
    ) -> Dict[str, Decimal]:
        """Calculate price momentum."""
        if base_date is None:
            base_date = date.today()

        start_date = base_date - timedelta(days=months * 30)
        skip_date = base_date - timedelta(days=skip_recent * 30)

        # Get prices
        prices = (
            self.db.query(Price)
            .filter(Price.ticker.in_(tickers))
            .filter(Price.date >= start_date)
            .filter(Price.date <= base_date)
            .all()
        )

        # Group by ticker
        ticker_prices = {}
        for p in prices:
            if p.ticker not in ticker_prices:
                ticker_prices[p.ticker] = []
            ticker_prices[p.ticker].append((p.date, float(p.close)))

        # Calculate returns
        momentum = {}
        for ticker, price_list in ticker_prices.items():
            if len(price_list) < 2:
                continue

            price_list.sort(key=lambda x: x[0])

            # Find start price
            start_price = price_list[0][1]

            # Find end price (skip recent month if specified)
            end_price = None
            for d, p in reversed(price_list):
                if d <= skip_date:
                    end_price = p
                    break

            if end_price and start_price > 0:
                momentum[ticker] = Decimal(str((end_price - start_price) / start_price * 100))

        return momentum

    def calculate_value_scores(
        self,
        valuations: Dict[str, Valuation],
    ) -> Dict[str, Decimal]:
        """Calculate value factor scores (higher is cheaper/better)."""
        data = []
        for ticker, v in valuations.items():
            # Inverse of PER, PBR, etc. (lower ratio = higher score)
            per_inv = 1 / float(v.per) if v.per and float(v.per) > 0 else 0
            pbr_inv = 1 / float(v.pbr) if v.pbr and float(v.pbr) > 0 else 0
            psr_inv = 1 / float(v.psr) if v.psr and float(v.psr) > 0 else 0
            pcr_inv = 1 / float(v.pcr) if v.pcr and float(v.pcr) > 0 else 0
            div_yield = float(v.dividend_yield) if v.dividend_yield else 0

            data.append({
                'ticker': ticker,
                'per_inv': per_inv,
                'pbr_inv': pbr_inv,
                'psr_inv': psr_inv,
                'pcr_inv': pcr_inv,
                'div_yield': div_yield,
            })

        if not data:
            return {}

        df = pd.DataFrame(data)

        # Z-score normalization for each metric
        for col in ['per_inv', 'pbr_inv', 'psr_inv', 'pcr_inv', 'div_yield']:
            mean = df[col].mean()
            std = df[col].std()
            if std > 0:
                df[f'{col}_z'] = (df[col] - mean) / std
            else:
                df[f'{col}_z'] = 0

        # Composite value score (equal weight)
        df['value_score'] = (
            df['per_inv_z'] + df['pbr_inv_z'] + df['psr_inv_z'] +
            df['pcr_inv_z'] + df['div_yield_z']
        ) / 5

        return {row['ticker']: Decimal(str(row['value_score'])) for _, row in df.iterrows()}

    def calculate_quality_scores(
        self,
        tickers: List[str],
        base_date: date = None,
    ) -> Dict[str, Decimal]:
        """Calculate quality factor scores based on ROE, GP/A, etc."""
        # Get financial data
        financials = (
            self.db.query(Financial)
            .filter(Financial.ticker.in_(tickers))
            .filter(Financial.report_type == 'annual')
            .all()
        )

        # Group by ticker
        ticker_financials = {}
        for f in financials:
            if f.ticker not in ticker_financials:
                ticker_financials[f.ticker] = {}
            ticker_financials[f.ticker][f.account] = float(f.value) if f.value else 0

        data = []
        for ticker, fin in ticker_financials.items():
            total_equity = fin.get('total_equity', 0)
            total_assets = fin.get('total_assets', 0)
            net_income = fin.get('net_income', 0)
            gross_profit = fin.get('gross_profit', 0)
            operating_cf = fin.get('operating_cash_flow', 0)
            total_liabilities = fin.get('total_liabilities', 0)

            roe = net_income / total_equity if total_equity > 0 else 0
            gpa = gross_profit / total_assets if total_assets > 0 else 0
            cfo_a = operating_cf / total_assets if total_assets > 0 else 0
            debt_ratio_inv = 1 / (total_liabilities / total_equity) if total_equity > 0 and total_liabilities > 0 else 0

            data.append({
                'ticker': ticker,
                'roe': roe,
                'gpa': gpa,
                'cfo_a': cfo_a,
                'debt_ratio_inv': debt_ratio_inv,
            })

        if not data:
            return {}

        df = pd.DataFrame(data)

        # Z-score normalization
        for col in ['roe', 'gpa', 'cfo_a', 'debt_ratio_inv']:
            mean = df[col].mean()
            std = df[col].std()
            if std > 0:
                df[f'{col}_z'] = (df[col] - mean) / std
            else:
                df[f'{col}_z'] = 0

        # Composite quality score
        df['quality_score'] = (df['roe_z'] + df['gpa_z'] + df['cfo_a_z'] + df['debt_ratio_inv_z']) / 4

        return {row['ticker']: Decimal(str(row['quality_score'])) for _, row in df.iterrows()}

    def calculate_fscore(
        self,
        tickers: List[str],
    ) -> Dict[str, int]:
        """Calculate Piotroski F-Score (0-9)."""
        # Get financial data for current and previous year
        financials = (
            self.db.query(Financial)
            .filter(Financial.ticker.in_(tickers))
            .filter(Financial.report_type == 'annual')
            .all()
        )

        # Group by ticker and date
        ticker_data = {}
        for f in financials:
            key = (f.ticker, f.base_date)
            if key not in ticker_data:
                ticker_data[key] = {}
            ticker_data[key][f.account] = float(f.value) if f.value else 0

        fscores = {}
        for ticker in tickers:
            # Get latest two years
            ticker_years = sorted(
                [(k, v) for k, v in ticker_data.items() if k[0] == ticker],
                key=lambda x: x[0][1],
                reverse=True
            )[:2]

            if len(ticker_years) < 2:
                fscores[ticker] = 0
                continue

            curr = ticker_years[0][1]
            prev = ticker_years[1][1]

            score = 0

            # Profitability (4 points)
            # 1. ROA > 0
            ta = curr.get('total_assets', 1)
            ni = curr.get('net_income', 0)
            if ta > 0 and ni / ta > 0:
                score += 1

            # 2. CFO > 0
            cfo = curr.get('operating_cash_flow', 0)
            if cfo > 0:
                score += 1

            # 3. ROA increased
            prev_ta = prev.get('total_assets', 1)
            prev_ni = prev.get('net_income', 0)
            if ta > 0 and prev_ta > 0:
                if ni / ta > prev_ni / prev_ta:
                    score += 1

            # 4. CFO > Net Income (Accrual)
            if cfo > ni:
                score += 1

            # Leverage (3 points)
            # 5. Leverage decreased
            tl = curr.get('total_liabilities', 0)
            prev_tl = prev.get('total_liabilities', 0)
            if ta > 0 and prev_ta > 0:
                if tl / ta < prev_tl / prev_ta:
                    score += 1

            # 6. Liquidity increased
            ca = curr.get('current_assets', 0)
            cl = curr.get('current_liabilities', 1)
            prev_ca = prev.get('current_assets', 0)
            prev_cl = prev.get('current_liabilities', 1)
            if cl > 0 and prev_cl > 0:
                if ca / cl > prev_ca / prev_cl:
                    score += 1

            # 7. No new equity issued (simplified: equity increase <= net income)
            te = curr.get('total_equity', 0)
            prev_te = prev.get('total_equity', 0)
            if te - prev_te <= ni:
                score += 1

            # Operating Efficiency (2 points)
            # 8. Gross margin improved
            rev = curr.get('revenue', 1)
            gp = curr.get('gross_profit', 0)
            prev_rev = prev.get('revenue', 1)
            prev_gp = prev.get('gross_profit', 0)
            if rev > 0 and prev_rev > 0:
                if gp / rev > prev_gp / prev_rev:
                    score += 1

            # 9. Asset turnover improved
            if ta > 0 and prev_ta > 0:
                if rev / ta > prev_rev / prev_ta:
                    score += 1

            fscores[ticker] = score

        return fscores

Step 2: Commit

git add backend/app/services/factor_calculator.py
git commit -m "feat: add factor calculator service"

Task 3: Multi-Factor Strategy Service

Files:

  • Create: backend/app/services/strategy/__init__.py
  • Create: backend/app/services/strategy/base.py
  • Create: backend/app/services/strategy/multi_factor.py

Step 1: Create strategy base class

# backend/app/services/strategy/__init__.py
from app.services.strategy.base import BaseStrategy
from app.services.strategy.multi_factor import MultiFactorStrategy
from app.services.strategy.quality import QualityStrategy
from app.services.strategy.value_momentum import ValueMomentumStrategy

__all__ = ["BaseStrategy", "MultiFactorStrategy", "QualityStrategy", "ValueMomentumStrategy"]
# backend/app/services/strategy/base.py
"""
Base strategy class.
"""
from abc import ABC, abstractmethod
from datetime import date
from typing import List

from sqlalchemy.orm import Session

from app.schemas.strategy import StockFactor, StrategyResult, UniverseFilter
from app.services.factor_calculator import FactorCalculator


class BaseStrategy(ABC):
    """Base class for quant strategies."""

    strategy_name: str = "base"

    def __init__(self, db: Session):
        self.db = db
        self.factor_calc = FactorCalculator(db)

    def get_universe(self, filter: UniverseFilter) -> List:
        """Get filtered stock universe."""
        return self.factor_calc.get_universe(
            markets=filter.markets,
            min_market_cap=filter.min_market_cap,
            max_market_cap=filter.max_market_cap,
            exclude_stock_types=filter.exclude_stock_types,
            exclude_sectors=filter.exclude_sectors,
        )

    @abstractmethod
    def run(
        self,
        universe_filter: UniverseFilter,
        top_n: int,
        base_date: date = None,
        **kwargs,
    ) -> StrategyResult:
        """Run the strategy and return ranked stocks."""
        pass

Step 2: Create multi-factor strategy

# backend/app/services/strategy/multi_factor.py
"""
Multi-factor strategy implementation.
"""
from datetime import date
from decimal import Decimal
from typing import List

from sqlalchemy.orm import Session

from app.services.strategy.base import BaseStrategy
from app.schemas.strategy import (
    StockFactor, StrategyResult, UniverseFilter, FactorWeights,
)


class MultiFactorStrategy(BaseStrategy):
    """Multi-factor strategy combining value, quality, and momentum."""

    strategy_name = "multi_factor"

    def run(
        self,
        universe_filter: UniverseFilter,
        top_n: int,
        base_date: date = None,
        weights: FactorWeights = None,
    ) -> StrategyResult:
        if base_date is None:
            base_date = date.today()
        if weights is None:
            weights = FactorWeights()

        # Get universe
        stocks = self.get_universe(universe_filter)
        tickers = [s.ticker for s in stocks]
        stock_map = {s.ticker: s for s in stocks}

        if not tickers:
            return StrategyResult(
                strategy_name=self.strategy_name,
                base_date=base_date,
                universe_count=0,
                result_count=0,
                stocks=[],
            )

        # Get valuations and sectors
        valuations = self.factor_calc.get_valuations(tickers, base_date)
        sectors = self.factor_calc.get_sectors(tickers)

        # Calculate factor scores
        value_scores = self.factor_calc.calculate_value_scores(valuations)
        quality_scores = self.factor_calc.calculate_quality_scores(tickers, base_date)
        momentum = self.factor_calc.calculate_momentum(tickers, base_date)

        # Normalize momentum to z-scores
        if momentum:
            mom_values = list(momentum.values())
            mom_mean = sum(mom_values) / len(mom_values)
            mom_std = (sum((v - mom_mean) ** 2 for v in mom_values) / len(mom_values)) ** 0.5
            if mom_std > 0:
                momentum_scores = {
                    t: Decimal(str((float(v) - float(mom_mean)) / float(mom_std)))
                    for t, v in momentum.items()
                }
            else:
                momentum_scores = {t: Decimal("0") for t in momentum}
        else:
            momentum_scores = {}

        # Build result
        results = []
        for ticker in tickers:
            stock = stock_map[ticker]
            val = valuations.get(ticker)

            v_score = value_scores.get(ticker, Decimal("0"))
            q_score = quality_scores.get(ticker, Decimal("0"))
            m_score = momentum_scores.get(ticker, Decimal("0"))

            # Weighted composite
            total = (
                v_score * weights.value +
                q_score * weights.quality +
                m_score * weights.momentum
            )

            results.append(StockFactor(
                ticker=ticker,
                name=stock.name,
                market=stock.market,
                sector_name=sectors.get(ticker),
                market_cap=int(stock.market_cap / 100_000_000) if stock.market_cap else None,
                close_price=Decimal(str(stock.close_price)) if stock.close_price else None,
                per=Decimal(str(val.per)) if val and val.per else None,
                pbr=Decimal(str(val.pbr)) if val and val.pbr else None,
                psr=Decimal(str(val.psr)) if val and val.psr else None,
                pcr=Decimal(str(val.pcr)) if val and val.pcr else None,
                dividend_yield=Decimal(str(val.dividend_yield)) if val and val.dividend_yield else None,
                value_score=v_score,
                quality_score=q_score,
                momentum_score=m_score,
                total_score=total,
            ))

        # Sort by total score descending
        results.sort(key=lambda x: x.total_score or Decimal("0"), reverse=True)

        # Assign ranks and limit
        for i, r in enumerate(results[:top_n], 1):
            r.rank = i

        return StrategyResult(
            strategy_name=self.strategy_name,
            base_date=base_date,
            universe_count=len(stocks),
            result_count=min(top_n, len(results)),
            stocks=results[:top_n],
        )

Step 3: Commit

git add backend/app/services/strategy/
git commit -m "feat: add multi-factor strategy service"

Task 4: Quality and Value-Momentum Strategies

Files:

  • Create: backend/app/services/strategy/quality.py
  • Create: backend/app/services/strategy/value_momentum.py

Step 1: Create quality strategy

# backend/app/services/strategy/quality.py
"""
Super Quality strategy implementation.
"""
from datetime import date
from decimal import Decimal

from app.services.strategy.base import BaseStrategy
from app.schemas.strategy import StockFactor, StrategyResult, UniverseFilter


class QualityStrategy(BaseStrategy):
    """Super Quality strategy using F-Score and quality factors."""

    strategy_name = "quality"

    def run(
        self,
        universe_filter: UniverseFilter,
        top_n: int,
        base_date: date = None,
        min_fscore: int = 7,
    ) -> StrategyResult:
        if base_date is None:
            base_date = date.today()

        # Get universe
        stocks = self.get_universe(universe_filter)
        tickers = [s.ticker for s in stocks]
        stock_map = {s.ticker: s for s in stocks}

        if not tickers:
            return StrategyResult(
                strategy_name=self.strategy_name,
                base_date=base_date,
                universe_count=0,
                result_count=0,
                stocks=[],
            )

        # Get data
        valuations = self.factor_calc.get_valuations(tickers, base_date)
        sectors = self.factor_calc.get_sectors(tickers)
        fscores = self.factor_calc.calculate_fscore(tickers)
        quality_scores = self.factor_calc.calculate_quality_scores(tickers, base_date)

        # Filter by F-Score
        qualified_tickers = [t for t in tickers if fscores.get(t, 0) >= min_fscore]

        # Build results
        results = []
        for ticker in qualified_tickers:
            stock = stock_map[ticker]
            val = valuations.get(ticker)
            q_score = quality_scores.get(ticker, Decimal("0"))
            fscore = fscores.get(ticker, 0)

            results.append(StockFactor(
                ticker=ticker,
                name=stock.name,
                market=stock.market,
                sector_name=sectors.get(ticker),
                market_cap=int(stock.market_cap / 100_000_000) if stock.market_cap else None,
                close_price=Decimal(str(stock.close_price)) if stock.close_price else None,
                per=Decimal(str(val.per)) if val and val.per else None,
                pbr=Decimal(str(val.pbr)) if val and val.pbr else None,
                dividend_yield=Decimal(str(val.dividend_yield)) if val and val.dividend_yield else None,
                quality_score=q_score,
                total_score=q_score,
                fscore=fscore,
            ))

        # Sort by quality score
        results.sort(key=lambda x: x.total_score or Decimal("0"), reverse=True)

        for i, r in enumerate(results[:top_n], 1):
            r.rank = i

        return StrategyResult(
            strategy_name=self.strategy_name,
            base_date=base_date,
            universe_count=len(stocks),
            result_count=min(top_n, len(results)),
            stocks=results[:top_n],
        )

Step 2: Create value-momentum strategy

# backend/app/services/strategy/value_momentum.py
"""
Value-Momentum strategy implementation.
"""
from datetime import date
from decimal import Decimal

from app.services.strategy.base import BaseStrategy
from app.schemas.strategy import StockFactor, StrategyResult, UniverseFilter


class ValueMomentumStrategy(BaseStrategy):
    """Value-Momentum combined strategy."""

    strategy_name = "value_momentum"

    def run(
        self,
        universe_filter: UniverseFilter,
        top_n: int,
        base_date: date = None,
        value_weight: Decimal = Decimal("0.5"),
        momentum_weight: Decimal = Decimal("0.5"),
    ) -> StrategyResult:
        if base_date is None:
            base_date = date.today()

        # Get universe
        stocks = self.get_universe(universe_filter)
        tickers = [s.ticker for s in stocks]
        stock_map = {s.ticker: s for s in stocks}

        if not tickers:
            return StrategyResult(
                strategy_name=self.strategy_name,
                base_date=base_date,
                universe_count=0,
                result_count=0,
                stocks=[],
            )

        # Get data
        valuations = self.factor_calc.get_valuations(tickers, base_date)
        sectors = self.factor_calc.get_sectors(tickers)
        value_scores = self.factor_calc.calculate_value_scores(valuations)
        momentum = self.factor_calc.calculate_momentum(tickers, base_date)

        # Normalize momentum
        if momentum:
            mom_values = list(momentum.values())
            mom_mean = sum(mom_values) / len(mom_values)
            mom_std = (sum((v - mom_mean) ** 2 for v in mom_values) / len(mom_values)) ** 0.5
            if mom_std > 0:
                momentum_scores = {
                    t: Decimal(str((float(v) - float(mom_mean)) / float(mom_std)))
                    for t, v in momentum.items()
                }
            else:
                momentum_scores = {t: Decimal("0") for t in momentum}
        else:
            momentum_scores = {}

        # Build results
        results = []
        for ticker in tickers:
            stock = stock_map[ticker]
            val = valuations.get(ticker)
            v_score = value_scores.get(ticker, Decimal("0"))
            m_score = momentum_scores.get(ticker, Decimal("0"))

            total = v_score * value_weight + m_score * momentum_weight

            results.append(StockFactor(
                ticker=ticker,
                name=stock.name,
                market=stock.market,
                sector_name=sectors.get(ticker),
                market_cap=int(stock.market_cap / 100_000_000) if stock.market_cap else None,
                close_price=Decimal(str(stock.close_price)) if stock.close_price else None,
                per=Decimal(str(val.per)) if val and val.per else None,
                pbr=Decimal(str(val.pbr)) if val and val.pbr else None,
                dividend_yield=Decimal(str(val.dividend_yield)) if val and val.dividend_yield else None,
                value_score=v_score,
                momentum_score=m_score,
                total_score=total,
            ))

        results.sort(key=lambda x: x.total_score or Decimal("0"), reverse=True)

        for i, r in enumerate(results[:top_n], 1):
            r.rank = i

        return StrategyResult(
            strategy_name=self.strategy_name,
            base_date=base_date,
            universe_count=len(stocks),
            result_count=min(top_n, len(results)),
            stocks=results[:top_n],
        )

Step 3: Update init.py to include new strategies

Already done in Task 3.

Step 4: Commit

git add backend/app/services/strategy/
git commit -m "feat: add quality and value-momentum strategies"

Task 5: Strategy API Endpoints

Files:

  • Create: backend/app/api/strategy.py
  • Update: backend/app/api/__init__.py
  • Update: backend/app/main.py

Step 1: Create strategy API

# backend/app/api/strategy.py
"""
Quant strategy API endpoints.
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.api.deps import CurrentUser
from app.schemas.strategy import (
    MultiFactorRequest, QualityRequest, ValueMomentumRequest, StrategyResult,
)
from app.services.strategy import MultiFactorStrategy, QualityStrategy, ValueMomentumStrategy

router = APIRouter(prefix="/api/strategy", tags=["strategy"])


@router.post("/multi-factor", response_model=StrategyResult)
async def run_multi_factor(
    request: MultiFactorRequest,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Run multi-factor strategy."""
    strategy = MultiFactorStrategy(db)
    return strategy.run(
        universe_filter=request.universe,
        top_n=request.top_n,
        base_date=request.base_date,
        weights=request.weights,
    )


@router.post("/quality", response_model=StrategyResult)
async def run_quality(
    request: QualityRequest,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Run super quality strategy."""
    strategy = QualityStrategy(db)
    return strategy.run(
        universe_filter=request.universe,
        top_n=request.top_n,
        base_date=request.base_date,
        min_fscore=request.min_fscore,
    )


@router.post("/value-momentum", response_model=StrategyResult)
async def run_value_momentum(
    request: ValueMomentumRequest,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Run value-momentum strategy."""
    strategy = ValueMomentumStrategy(db)
    return strategy.run(
        universe_filter=request.universe,
        top_n=request.top_n,
        base_date=request.base_date,
        value_weight=request.value_weight,
        momentum_weight=request.momentum_weight,
    )

Step 2: Update api/init.py

Add:

from app.api.strategy import router as strategy_router

__all__ = ["auth_router", "admin_router", "portfolio_router", "strategy_router"]

Step 3: Update main.py

Add:

from app.api import auth_router, admin_router, portfolio_router, strategy_router

app.include_router(strategy_router)

Step 4: Commit

git add backend/app/api/
git add backend/app/main.py
git commit -m "feat: add strategy API endpoints"

Task 6: Market API Endpoints

Files:

  • Create: backend/app/api/market.py
  • Update: backend/app/api/__init__.py
  • Update: backend/app/main.py

Step 1: Create market API

# backend/app/api/market.py
"""
Market data API endpoints.
"""
from typing import List, Optional
from datetime import date, timedelta

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.stock import Stock, Valuation, Price, Sector
from app.schemas.strategy import StockInfo, StockSearchResult, PriceData

router = APIRouter(prefix="/api/market", tags=["market"])


@router.get("/stocks/{ticker}", response_model=StockInfo)
async def get_stock(
    ticker: str,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Get stock information."""
    stock = db.query(Stock).filter(Stock.ticker == ticker).first()
    if not stock:
        raise HTTPException(status_code=404, detail="Stock not found")

    valuation = (
        db.query(Valuation)
        .filter(Valuation.ticker == ticker)
        .order_by(Valuation.base_date.desc())
        .first()
    )

    sector = db.query(Sector).filter(Sector.ticker == ticker).first()

    return StockInfo(
        ticker=stock.ticker,
        name=stock.name,
        market=stock.market,
        sector_name=sector.sector_name if sector else None,
        stock_type=stock.stock_type.value if stock.stock_type else "common",
        close_price=stock.close_price,
        market_cap=int(stock.market_cap / 100_000_000) if stock.market_cap else None,
        per=valuation.per if valuation else None,
        pbr=valuation.pbr if valuation else None,
        psr=valuation.psr if valuation else None,
        pcr=valuation.pcr if valuation else None,
        dividend_yield=valuation.dividend_yield if valuation else None,
        eps=stock.eps,
        bps=stock.bps,
        base_date=stock.base_date,
    )


@router.get("/stocks/{ticker}/prices", response_model=List[PriceData])
async def get_stock_prices(
    ticker: str,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
    days: int = Query(default=365, ge=1, le=3650),
):
    """Get historical prices for a stock."""
    start_date = date.today() - timedelta(days=days)

    prices = (
        db.query(Price)
        .filter(Price.ticker == ticker)
        .filter(Price.date >= start_date)
        .order_by(Price.date.desc())
        .all()
    )

    return [
        PriceData(
            date=p.date,
            open=p.open,
            high=p.high,
            low=p.low,
            close=p.close,
            volume=p.volume,
        )
        for p in prices
    ]


@router.get("/search", response_model=List[StockSearchResult])
async def search_stocks(
    q: str = Query(..., min_length=1),
    current_user: CurrentUser = None,
    db: Session = Depends(get_db),
    limit: int = Query(default=20, ge=1, le=100),
):
    """Search stocks by ticker or name."""
    stocks = (
        db.query(Stock)
        .filter(
            (Stock.ticker.ilike(f"%{q}%")) | (Stock.name.ilike(f"%{q}%"))
        )
        .limit(limit)
        .all()
    )

    return [
        StockSearchResult(
            ticker=s.ticker,
            name=s.name,
            market=s.market,
        )
        for s in stocks
    ]

Step 2: Update api/init.py

Add:

from app.api.market import router as market_router

__all__ = ["auth_router", "admin_router", "portfolio_router", "strategy_router", "market_router"]

Step 3: Update main.py

Add to imports and include router.

Step 4: Commit

git add backend/app/api/
git add backend/app/main.py
git commit -m "feat: add market data API endpoints"

Task 7: Frontend Strategy List Page

Files:

  • Create: frontend/src/app/strategy/page.tsx

Step 1: Create strategy list page

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';

interface User {
  id: number;
  username: string;
}

const strategies = [
  {
    id: 'multi-factor',
    name: '멀티 팩터',
    description: '밸류, 퀄리티, 모멘텀 팩터를 조합한 종합 전략',
    icon: '📊',
  },
  {
    id: 'quality',
    name: '슈퍼 퀄리티',
    description: 'F-Score 기반 우량주 선별 전략',
    icon: '⭐',
  },
  {
    id: 'value-momentum',
    name: '밸류 모멘텀',
    description: '가치주와 모멘텀을 결합한 전략',
    icon: '📈',
  },
];

export default function StrategyListPage() {
  const router = useRouter();
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const init = async () => {
      try {
        const userData = await api.getCurrentUser() as User;
        setUser(userData);
      } catch {
        router.push('/login');
      } finally {
        setLoading(false);
      }
    };
    init();
  }, [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-3 gap-6">
            {strategies.map((strategy) => (
              <Link
                key={strategy.id}
                href={`/strategy/${strategy.id}`}
                className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
              >
                <div className="text-4xl mb-4">{strategy.icon}</div>
                <h2 className="text-lg font-semibold text-gray-800 mb-2">
                  {strategy.name}
                </h2>
                <p className="text-sm text-gray-500">{strategy.description}</p>
              </Link>
            ))}
          </div>
        </main>
      </div>
    </div>
  );
}

Step 2: Commit

git add frontend/src/app/strategy/
git commit -m "feat: add strategy list page"

Task 8: Frontend Multi-Factor Strategy Page

Files:

  • Create: frontend/src/app/strategy/multi-factor/page.tsx

Step 1: Create multi-factor strategy page

'use client';

import { 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 StockFactor {
  ticker: string;
  name: string;
  market: string;
  sector_name: string | null;
  market_cap: number | null;
  close_price: number | null;
  per: number | null;
  pbr: number | null;
  value_score: number | null;
  quality_score: number | null;
  momentum_score: number | null;
  total_score: number | null;
  rank: number | null;
}

interface StrategyResult {
  strategy_name: string;
  base_date: string;
  universe_count: number;
  result_count: number;
  stocks: StockFactor[];
}

export default function MultiFactorPage() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<StrategyResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  // Weights
  const [valueWeight, setValueWeight] = useState(0.25);
  const [qualityWeight, setQualityWeight] = useState(0.25);
  const [momentumWeight, setMomentumWeight] = useState(0.25);
  const [topN, setTopN] = useState(30);

  const runStrategy = async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await api.post<StrategyResult>('/api/strategy/multi-factor', {
        universe: {
          markets: ['KOSPI', 'KOSDAQ'],
          exclude_stock_types: ['spac', 'preferred', 'reit'],
        },
        top_n: topN,
        weights: {
          value: valueWeight,
          quality: qualityWeight,
          momentum: momentumWeight,
          low_vol: 1 - valueWeight - qualityWeight - momentumWeight,
        },
      });
      setResult(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Strategy execution failed');
    } finally {
      setLoading(false);
    }
  };

  const formatNumber = (value: number | null, decimals: number = 2) => {
    if (value === null) return '-';
    return value.toFixed(decimals);
  };

  const formatCurrency = (value: number | null) => {
    if (value === null) return '-';
    return new Intl.NumberFormat('ko-KR').format(value);
  };

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <div className="flex-1">
        <Header />
        <main className="p-6">
          <h1 className="text-2xl font-bold text-gray-800 mb-6">멀티 팩터 전략</h1>

          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
              {error}
            </div>
          )}

          {/* Settings */}
          <div className="bg-white rounded-lg shadow p-6 mb-6">
            <h2 className="text-lg font-semibold mb-4">전략 설정</h2>
            <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
              <div>
                <label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
                  밸류 가중치
                </label>
                <input
                  id="value-weight"
                  type="number"
                  min="0"
                  max="1"
                  step="0.05"
                  value={valueWeight}
                  onChange={(e) => setValueWeight(parseFloat(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="quality-weight" className="block text-sm font-medium text-gray-700 mb-1">
                  퀄리티 가중치
                </label>
                <input
                  id="quality-weight"
                  type="number"
                  min="0"
                  max="1"
                  step="0.05"
                  value={qualityWeight}
                  onChange={(e) => setQualityWeight(parseFloat(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
                  모멘텀 가중치
                </label>
                <input
                  id="momentum-weight"
                  type="number"
                  min="0"
                  max="1"
                  step="0.05"
                  value={momentumWeight}
                  onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
                  상위 종목 
                </label>
                <input
                  id="top-n"
                  type="number"
                  min="1"
                  max="100"
                  value={topN}
                  onChange={(e) => setTopN(parseInt(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
            </div>
            <button
              onClick={runStrategy}
              disabled={loading}
              className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
            >
              {loading ? '실행 중...' : '전략 실행'}
            </button>
          </div>

          {/* Results */}
          {result && (
            <div className="bg-white rounded-lg shadow">
              <div className="p-4 border-b">
                <h2 className="text-lg font-semibold">
                  결과 ({result.result_count}/{result.universe_count} 종목)
                </h2>
                <p className="text-sm text-gray-500">기준일: {result.base_date}</p>
              </div>
              <div className="overflow-x-auto">
                <table className="w-full">
                  <thead className="bg-gray-50">
                    <tr>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액()</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">밸류</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">퀄리티</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">모멘텀</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">종합</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y">
                    {result.stocks.map((stock) => (
                      <tr key={stock.ticker} className="hover:bg-gray-50">
                        <td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
                        <td className="px-4 py-3">
                          <div className="font-medium">{stock.ticker}</div>
                          <div className="text-xs text-gray-500">{stock.name}</div>
                        </td>
                        <td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.quality_score)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
                        <td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </main>
      </div>
    </div>
  );
}

Step 2: Commit

git add frontend/src/app/strategy/multi-factor/
git commit -m "feat: add multi-factor strategy page"

Task 9: Frontend Quality Strategy Page

Files:

  • Create: frontend/src/app/strategy/quality/page.tsx

Step 1: Create quality strategy page

'use client';

import { useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';

interface StockFactor {
  ticker: string;
  name: string;
  market: string;
  sector_name: string | null;
  market_cap: number | null;
  close_price: number | null;
  per: number | null;
  pbr: number | null;
  dividend_yield: number | null;
  quality_score: number | null;
  fscore: number | null;
  rank: number | null;
}

interface StrategyResult {
  strategy_name: string;
  base_date: string;
  universe_count: number;
  result_count: number;
  stocks: StockFactor[];
}

export default function QualityStrategyPage() {
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<StrategyResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const [minFscore, setMinFscore] = useState(7);
  const [topN, setTopN] = useState(30);

  const runStrategy = async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await api.post<StrategyResult>('/api/strategy/quality', {
        universe: {
          markets: ['KOSPI', 'KOSDAQ'],
          exclude_stock_types: ['spac', 'preferred', 'reit'],
        },
        top_n: topN,
        min_fscore: minFscore,
      });
      setResult(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Strategy execution failed');
    } finally {
      setLoading(false);
    }
  };

  const formatNumber = (value: number | null, decimals: number = 2) => {
    if (value === null) return '-';
    return value.toFixed(decimals);
  };

  const formatCurrency = (value: number | null) => {
    if (value === null) return '-';
    return new Intl.NumberFormat('ko-KR').format(value);
  };

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <div className="flex-1">
        <Header />
        <main className="p-6">
          <h1 className="text-2xl font-bold text-gray-800 mb-6">슈퍼 퀄리티 전략</h1>

          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
              {error}
            </div>
          )}

          {/* Settings */}
          <div className="bg-white rounded-lg shadow p-6 mb-6">
            <h2 className="text-lg font-semibold mb-4">전략 설정</h2>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
              <div>
                <label htmlFor="min-fscore" className="block text-sm font-medium text-gray-700 mb-1">
                  최소 F-Score
                </label>
                <input
                  id="min-fscore"
                  type="number"
                  min="0"
                  max="9"
                  value={minFscore}
                  onChange={(e) => setMinFscore(parseInt(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
                  상위 종목 
                </label>
                <input
                  id="top-n"
                  type="number"
                  min="1"
                  max="100"
                  value={topN}
                  onChange={(e) => setTopN(parseInt(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
            </div>
            <button
              onClick={runStrategy}
              disabled={loading}
              className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
            >
              {loading ? '실행 중...' : '전략 실행'}
            </button>
          </div>

          {/* Results */}
          {result && (
            <div className="bg-white rounded-lg shadow">
              <div className="p-4 border-b">
                <h2 className="text-lg font-semibold">
                  결과 ({result.result_count}/{result.universe_count} 종목)
                </h2>
                <p className="text-sm text-gray-500">기준일: {result.base_date}</p>
              </div>
              <div className="overflow-x-auto">
                <table className="w-full">
                  <thead className="bg-gray-50">
                    <tr>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액()</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">배당률</th>
                      <th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">F-Score</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">퀄리티</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y">
                    {result.stocks.map((stock) => (
                      <tr key={stock.ticker} className="hover:bg-gray-50">
                        <td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
                        <td className="px-4 py-3">
                          <div className="font-medium">{stock.ticker}</div>
                          <div className="text-xs text-gray-500">{stock.name}</div>
                        </td>
                        <td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.dividend_yield)}%</td>
                        <td className="px-4 py-3 text-center">
                          <span className={`px-2 py-1 rounded text-xs ${
                            (stock.fscore ?? 0) >= 8 ? 'bg-green-100 text-green-800' :
                            (stock.fscore ?? 0) >= 6 ? 'bg-yellow-100 text-yellow-800' :
                            'bg-gray-100 text-gray-800'
                          }`}>
                            {stock.fscore}/9
                          </span>
                        </td>
                        <td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.quality_score)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </main>
      </div>
    </div>
  );
}

Step 2: Commit

git add frontend/src/app/strategy/quality/
git commit -m "feat: add quality strategy page"

Task 10: Frontend Value-Momentum Strategy Page

Files:

  • Create: frontend/src/app/strategy/value-momentum/page.tsx

Step 1: Create value-momentum strategy page

'use client';

import { useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';

interface StockFactor {
  ticker: string;
  name: string;
  market: string;
  sector_name: string | null;
  market_cap: number | null;
  close_price: number | null;
  per: number | null;
  pbr: number | null;
  dividend_yield: number | null;
  value_score: number | null;
  momentum_score: number | null;
  total_score: number | null;
  rank: number | null;
}

interface StrategyResult {
  strategy_name: string;
  base_date: string;
  universe_count: number;
  result_count: number;
  stocks: StockFactor[];
}

export default function ValueMomentumPage() {
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<StrategyResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  const [valueWeight, setValueWeight] = useState(0.5);
  const [momentumWeight, setMomentumWeight] = useState(0.5);
  const [topN, setTopN] = useState(30);

  const runStrategy = async () => {
    setLoading(true);
    setError(null);
    try {
      const data = await api.post<StrategyResult>('/api/strategy/value-momentum', {
        universe: {
          markets: ['KOSPI', 'KOSDAQ'],
          exclude_stock_types: ['spac', 'preferred', 'reit'],
        },
        top_n: topN,
        value_weight: valueWeight,
        momentum_weight: momentumWeight,
      });
      setResult(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Strategy execution failed');
    } finally {
      setLoading(false);
    }
  };

  const formatNumber = (value: number | null, decimals: number = 2) => {
    if (value === null) return '-';
    return value.toFixed(decimals);
  };

  const formatCurrency = (value: number | null) => {
    if (value === null) return '-';
    return new Intl.NumberFormat('ko-KR').format(value);
  };

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <div className="flex-1">
        <Header />
        <main className="p-6">
          <h1 className="text-2xl font-bold text-gray-800 mb-6">밸류 모멘텀 전략</h1>

          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
              {error}
            </div>
          )}

          {/* Settings */}
          <div className="bg-white rounded-lg shadow p-6 mb-6">
            <h2 className="text-lg font-semibold mb-4">전략 설정</h2>
            <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
              <div>
                <label htmlFor="value-weight" className="block text-sm font-medium text-gray-700 mb-1">
                  밸류 가중치
                </label>
                <input
                  id="value-weight"
                  type="number"
                  min="0"
                  max="1"
                  step="0.1"
                  value={valueWeight}
                  onChange={(e) => setValueWeight(parseFloat(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="momentum-weight" className="block text-sm font-medium text-gray-700 mb-1">
                  모멘텀 가중치
                </label>
                <input
                  id="momentum-weight"
                  type="number"
                  min="0"
                  max="1"
                  step="0.1"
                  value={momentumWeight}
                  onChange={(e) => setMomentumWeight(parseFloat(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
              <div>
                <label htmlFor="top-n" className="block text-sm font-medium text-gray-700 mb-1">
                  상위 종목 
                </label>
                <input
                  id="top-n"
                  type="number"
                  min="1"
                  max="100"
                  value={topN}
                  onChange={(e) => setTopN(parseInt(e.target.value))}
                  className="w-full px-3 py-2 border rounded"
                />
              </div>
            </div>
            <button
              onClick={runStrategy}
              disabled={loading}
              className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400"
            >
              {loading ? '실행 중...' : '전략 실행'}
            </button>
          </div>

          {/* Results */}
          {result && (
            <div className="bg-white rounded-lg shadow">
              <div className="p-4 border-b">
                <h2 className="text-lg font-semibold">
                  결과 ({result.result_count}/{result.universe_count} 종목)
                </h2>
                <p className="text-sm text-gray-500">기준일: {result.base_date}</p>
              </div>
              <div className="overflow-x-auto">
                <table className="w-full">
                  <thead className="bg-gray-50">
                    <tr>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">순위</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">섹터</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">시가총액()</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PER</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">PBR</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">밸류</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">모멘텀</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">종합</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y">
                    {result.stocks.map((stock) => (
                      <tr key={stock.ticker} className="hover:bg-gray-50">
                        <td className="px-4 py-3 text-sm font-medium">{stock.rank}</td>
                        <td className="px-4 py-3">
                          <div className="font-medium">{stock.ticker}</div>
                          <div className="text-xs text-gray-500">{stock.name}</div>
                        </td>
                        <td className="px-4 py-3 text-sm">{stock.sector_name || '-'}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.market_cap)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatCurrency(stock.close_price)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.per)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.pbr)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.value_score)}</td>
                        <td className="px-4 py-3 text-sm text-right">{formatNumber(stock.momentum_score)}</td>
                        <td className="px-4 py-3 text-sm text-right font-medium">{formatNumber(stock.total_score)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}
        </main>
      </div>
    </div>
  );
}

Step 2: Commit

git add frontend/src/app/strategy/value-momentum/
git commit -m "feat: add value-momentum strategy page"

Task 11: Verify Phase 4 Integration

Step 1: Verify all backend files exist

Check:

  • backend/app/schemas/strategy.py
  • backend/app/services/factor_calculator.py
  • backend/app/services/strategy/base.py
  • backend/app/services/strategy/multi_factor.py
  • backend/app/services/strategy/quality.py
  • backend/app/services/strategy/value_momentum.py
  • backend/app/api/strategy.py
  • backend/app/api/market.py

Step 2: Verify all frontend files exist

Check:

  • frontend/src/app/strategy/page.tsx
  • frontend/src/app/strategy/multi-factor/page.tsx
  • frontend/src/app/strategy/quality/page.tsx
  • frontend/src/app/strategy/value-momentum/page.tsx

Step 3: Run frontend build

cd /home/zephyrdark/workspace/quant/galaxy-po/frontend
npm run build

Step 4: Show git log

cd /home/zephyrdark/workspace/quant/galaxy-po
git log --oneline -15

Summary

Phase 4 완료 시 구현된 기능:

  • Strategy Pydantic 스키마 (FactorWeights, UniverseFilter, StrategyRequest, StockFactor, etc.)
  • Factor Calculator 서비스 (밸류, 퀄리티, 모멘텀, F-Score 계산)
  • Multi-Factor 전략 (가중치 기반 종합 점수)
  • Super Quality 전략 (F-Score 필터 + 퀄리티 점수)
  • Value-Momentum 전략 (밸류 + 모멘텀 조합)
  • Strategy API 엔드포인트 (multi-factor, quality, value-momentum)
  • Market API 엔드포인트 (종목 조회, 시세 조회, 검색)
  • Frontend 전략 목록 페이지
  • Frontend 멀티 팩터 전략 페이지
  • Frontend 슈퍼 퀄리티 전략 페이지
  • Frontend 밸류 모멘텀 전략 페이지

다음 Phase: 백테스트 엔진 구현