# 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** ```python """ 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__: ```python from app.schemas.strategy import ( FactorWeights, UniverseFilter, StrategyRequest, MultiFactorRequest, QualityRequest, ValueMomentumRequest, StockFactor, StrategyResult, StockInfo, StockSearchResult, PriceData, ) ``` **Step 3: Commit** ```bash 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** ```python """ 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** ```bash 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** ```python # 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"] ``` ```python # 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** ```python # 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** ```bash 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** ```python # 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** ```python # 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** ```bash 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** ```python # 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: ```python from app.api.strategy import router as strategy_router __all__ = ["auth_router", "admin_router", "portfolio_router", "strategy_router"] ``` **Step 3: Update main.py** Add: ```python from app.api import auth_router, admin_router, portfolio_router, strategy_router app.include_router(strategy_router) ``` **Step 4: Commit** ```bash 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** ```python # 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: ```python 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** ```bash 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** ```typescript '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(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 (
Loading...
); } return (

퀀트 전략

{strategies.map((strategy) => (
{strategy.icon}

{strategy.name}

{strategy.description}

))}
); } ``` **Step 2: Commit** ```bash 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** ```typescript '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(null); const [error, setError] = useState(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('/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 (

멀티 팩터 전략

{error && (
{error}
)} {/* Settings */}

전략 설정

setValueWeight(parseFloat(e.target.value))} className="w-full px-3 py-2 border rounded" />
setQualityWeight(parseFloat(e.target.value))} className="w-full px-3 py-2 border rounded" />
setMomentumWeight(parseFloat(e.target.value))} className="w-full px-3 py-2 border rounded" />
setTopN(parseInt(e.target.value))} className="w-full px-3 py-2 border rounded" />
{/* Results */} {result && (

결과 ({result.result_count}/{result.universe_count} 종목)

기준일: {result.base_date}

{result.stocks.map((stock) => ( ))}
순위 종목 섹터 시가총액(억) 현재가 PER PBR 밸류 퀄리티 모멘텀 종합
{stock.rank}
{stock.ticker}
{stock.name}
{stock.sector_name || '-'} {formatCurrency(stock.market_cap)} {formatCurrency(stock.close_price)} {formatNumber(stock.per)} {formatNumber(stock.pbr)} {formatNumber(stock.value_score)} {formatNumber(stock.quality_score)} {formatNumber(stock.momentum_score)} {formatNumber(stock.total_score)}
)}
); } ``` **Step 2: Commit** ```bash 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** ```typescript '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(null); const [error, setError] = useState(null); const [minFscore, setMinFscore] = useState(7); const [topN, setTopN] = useState(30); const runStrategy = async () => { setLoading(true); setError(null); try { const data = await api.post('/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 (

슈퍼 퀄리티 전략

{error && (
{error}
)} {/* Settings */}

전략 설정

setMinFscore(parseInt(e.target.value))} className="w-full px-3 py-2 border rounded" />
setTopN(parseInt(e.target.value))} className="w-full px-3 py-2 border rounded" />
{/* Results */} {result && (

결과 ({result.result_count}/{result.universe_count} 종목)

기준일: {result.base_date}

{result.stocks.map((stock) => ( ))}
순위 종목 섹터 시가총액(억) 현재가 PER PBR 배당률 F-Score 퀄리티
{stock.rank}
{stock.ticker}
{stock.name}
{stock.sector_name || '-'} {formatCurrency(stock.market_cap)} {formatCurrency(stock.close_price)} {formatNumber(stock.per)} {formatNumber(stock.pbr)} {formatNumber(stock.dividend_yield)}% = 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 {formatNumber(stock.quality_score)}
)}
); } ``` **Step 2: Commit** ```bash 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** ```typescript '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(null); const [error, setError] = useState(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('/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 (

밸류 모멘텀 전략

{error && (
{error}
)} {/* Settings */}

전략 설정

setValueWeight(parseFloat(e.target.value))} className="w-full px-3 py-2 border rounded" />
setMomentumWeight(parseFloat(e.target.value))} className="w-full px-3 py-2 border rounded" />
setTopN(parseInt(e.target.value))} className="w-full px-3 py-2 border rounded" />
{/* Results */} {result && (

결과 ({result.result_count}/{result.universe_count} 종목)

기준일: {result.base_date}

{result.stocks.map((stock) => ( ))}
순위 종목 섹터 시가총액(억) 현재가 PER PBR 밸류 모멘텀 종합
{stock.rank}
{stock.ticker}
{stock.name}
{stock.sector_name || '-'} {formatCurrency(stock.market_cap)} {formatCurrency(stock.close_price)} {formatNumber(stock.per)} {formatNumber(stock.pbr)} {formatNumber(stock.value_score)} {formatNumber(stock.momentum_score)} {formatNumber(stock.total_score)}
)}
); } ``` **Step 2: Commit** ```bash 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** ```bash cd /home/zephyrdark/workspace/quant/galaxy-po/frontend npm run build ``` **Step 4: Show git log** ```bash 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: 백테스트 엔진 구현