179 lines
5.8 KiB
Python
179 lines
5.8 KiB
Python
"""Fundamental screening for swing trading candidates.
|
|
|
|
Pure computational screening (no LLM) - filters stocks by fundamental health:
|
|
- Revenue growth (positive QoQ or YoY)
|
|
- Reasonable valuation (PER, PBR)
|
|
- Financial health (debt ratio, current ratio)
|
|
- Market cap threshold
|
|
"""
|
|
|
|
import logging
|
|
|
|
import pandas as pd
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def fundamental_screen(
|
|
technical_candidates: list[dict],
|
|
trade_date: str,
|
|
market: str = "KRX",
|
|
) -> list[dict]:
|
|
"""Filter technical candidates by fundamental criteria.
|
|
|
|
Args:
|
|
technical_candidates: Output from technical_screener
|
|
trade_date: Current trading date (YYYY-MM-DD)
|
|
market: "KRX" or "US"
|
|
|
|
Returns:
|
|
Filtered list with fundamental data added
|
|
"""
|
|
if not technical_candidates:
|
|
return []
|
|
|
|
results = []
|
|
|
|
for candidate in technical_candidates:
|
|
ticker = candidate["ticker"]
|
|
|
|
try:
|
|
if market == "KRX":
|
|
fund_data = _get_krx_fundamentals(ticker, trade_date)
|
|
else:
|
|
fund_data = _get_us_fundamentals(ticker)
|
|
|
|
if not fund_data:
|
|
# No fundamental data available; still pass through
|
|
# with a warning flag
|
|
candidate["fundamental_check"] = "데이터 없음"
|
|
candidate["fundamental_pass"] = True # benefit of the doubt
|
|
results.append(candidate)
|
|
continue
|
|
|
|
# Apply fundamental filters
|
|
passes, reasons = _check_fundamentals(fund_data, market)
|
|
candidate["fundamentals"] = fund_data
|
|
candidate["fundamental_check"] = " / ".join(reasons) if reasons else "기본 통과"
|
|
candidate["fundamental_pass"] = passes
|
|
|
|
if passes:
|
|
results.append(candidate)
|
|
else:
|
|
logger.debug(
|
|
f"{ticker} failed fundamental screen: {reasons}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Fundamental screening error for {ticker}: {e}")
|
|
candidate["fundamental_check"] = f"오류: {e}"
|
|
candidate["fundamental_pass"] = True
|
|
results.append(candidate)
|
|
|
|
logger.info(
|
|
f"Fundamental screening: {len(results)}/{len(technical_candidates)} passed"
|
|
)
|
|
return results
|
|
|
|
|
|
def _get_krx_fundamentals(ticker: str, trade_date: str) -> dict:
|
|
"""Get KRX fundamental data for screening."""
|
|
data = {}
|
|
|
|
try:
|
|
from pykrx import stock as krx_stock
|
|
|
|
date_str = trade_date.replace("-", "")
|
|
|
|
# PER, PBR, EPS, BPS, DIV
|
|
fund_df = krx_stock.get_market_fundamental_by_date(
|
|
date_str, date_str, ticker
|
|
)
|
|
if fund_df is not None and not fund_df.empty:
|
|
row = fund_df.iloc[0]
|
|
data["per"] = row.get("PER", None)
|
|
data["pbr"] = row.get("PBR", None)
|
|
data["eps"] = row.get("EPS", None)
|
|
data["bps"] = row.get("BPS", None)
|
|
data["div_yield"] = row.get("DIV", None)
|
|
|
|
# Market cap
|
|
cap_df = krx_stock.get_market_cap_by_date(date_str, date_str, ticker)
|
|
if cap_df is not None and not cap_df.empty:
|
|
cap_row = cap_df.iloc[0]
|
|
data["market_cap"] = cap_row.get("시가총액", None)
|
|
|
|
except ImportError:
|
|
logger.warning("pykrx not installed - limited fundamental screening")
|
|
except Exception as e:
|
|
logger.warning(f"Error getting KRX fundamentals for {ticker}: {e}")
|
|
|
|
return data
|
|
|
|
|
|
def _get_us_fundamentals(ticker: str) -> dict:
|
|
"""Get US fundamental data for screening."""
|
|
import yfinance as yf
|
|
|
|
data = {}
|
|
try:
|
|
info = yf.Ticker(ticker).info
|
|
data["per"] = info.get("trailingPE")
|
|
data["forward_pe"] = info.get("forwardPE")
|
|
data["pbr"] = info.get("priceToBook")
|
|
data["eps"] = info.get("trailingEps")
|
|
data["div_yield"] = info.get("dividendYield")
|
|
data["market_cap"] = info.get("marketCap")
|
|
data["debt_to_equity"] = info.get("debtToEquity")
|
|
data["current_ratio"] = info.get("currentRatio")
|
|
data["profit_margin"] = info.get("profitMargins")
|
|
data["revenue_growth"] = info.get("revenueGrowth")
|
|
data["roe"] = info.get("returnOnEquity")
|
|
except Exception as e:
|
|
logger.warning(f"Error getting US fundamentals for {ticker}: {e}")
|
|
|
|
return data
|
|
|
|
|
|
def _check_fundamentals(data: dict, market: str) -> tuple[bool, list[str]]:
|
|
"""Check if fundamentals pass screening criteria.
|
|
|
|
Returns (passes: bool, reasons: list of fail/pass reasons).
|
|
"""
|
|
reasons = []
|
|
fail = False
|
|
|
|
# PER check: not excessively high (allow negative for turnaround plays)
|
|
per = data.get("per")
|
|
if per is not None and per > 0:
|
|
if per > 100:
|
|
reasons.append(f"PER 과다 ({per:.1f})")
|
|
fail = True
|
|
elif per < 5:
|
|
reasons.append(f"PER 매력적 ({per:.1f})")
|
|
|
|
# PBR check: not excessively high
|
|
pbr = data.get("pbr")
|
|
if pbr is not None and pbr > 0:
|
|
if pbr > 10:
|
|
reasons.append(f"PBR 과다 ({pbr:.1f})")
|
|
fail = True
|
|
|
|
# Debt check (US only - data available)
|
|
debt_to_equity = data.get("debt_to_equity")
|
|
if debt_to_equity is not None and debt_to_equity > 300:
|
|
reasons.append(f"부채비율 과다 ({debt_to_equity:.0f}%)")
|
|
fail = True
|
|
|
|
# Revenue growth (positive is good, not a hard filter)
|
|
rev_growth = data.get("revenue_growth")
|
|
if rev_growth is not None and rev_growth > 0:
|
|
reasons.append(f"매출 성장 (+{rev_growth * 100:.1f}%)")
|
|
|
|
# Profit margin (negative is a warning but not disqualifying for swing)
|
|
profit_margin = data.get("profit_margin")
|
|
if profit_margin is not None and profit_margin < -0.20:
|
|
reasons.append(f"적자 심화 (마진 {profit_margin * 100:.1f}%)")
|
|
|
|
return not fail, reasons
|