2033 lines
65 KiB
Markdown
2033 lines
65 KiB
Markdown
# 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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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: 백테스트 엔진 구현
|