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: 백테스트 엔진 구현