fix(discovery): commit risk_metrics.py and reduce Minervini max_tickers to 50
Two bugs causing zero recommendations: 1. risk_metrics.py was untracked — importing it raised ModuleNotFoundError which was caught by the outer try/except in filter.py, silently dropping all 32 candidates that reached the fundamental risk check stage. 2. Minervini scanner at max_tickers=200 took >5 min to download 200 tickers x 1y of OHLCV data. ThreadPoolExecutor.cancel() cannot kill a running thread, so the download kept running as a zombie thread for 20 more minutes after the pipeline completed, holding the Python process alive until the 30-min workflow timeout killed the entire job. Reducing to 50 tickers brings the download to ~75s, well under the 300s global scanner timeout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2da07c6797
commit
704af1a855
|
|
@ -0,0 +1,179 @@
|
|||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from tradingagents.dataflows.y_finance import suppress_yfinance_warnings
|
||||
from tradingagents.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def calculate_altman_z_score(ticker: str) -> float:
|
||||
"""
|
||||
Calculate the Altman Z-Score for a manufacturing company.
|
||||
If the company is non-manufacturing/service, a modified Z-score is often used,
|
||||
but here we stick to the standard/modified approximation for simplicity.
|
||||
Formula: Z = 1.2(X1) + 1.4(X2) + 3.3(X3) + 0.6(X4) + 1.0(X5)
|
||||
X1 = Working Capital / Total Assets
|
||||
X2 = Retained Earnings / Total Assets
|
||||
X3 = EBIT / Total Assets
|
||||
X4 = Market Value of Equity / Total Liabilities
|
||||
X5 = Sales / Total Assets
|
||||
Returns: float (Z-score). Values < 1.81 indicate distress.
|
||||
"""
|
||||
try:
|
||||
with suppress_yfinance_warnings():
|
||||
stock = yf.Ticker(ticker.upper())
|
||||
bs = stock.balance_sheet
|
||||
inc = stock.financials
|
||||
info = stock.info
|
||||
|
||||
if bs.empty or inc.empty:
|
||||
return None
|
||||
|
||||
# Get latest annual data
|
||||
latest_bs = bs.iloc[:, 0]
|
||||
latest_inc = inc.iloc[:, 0]
|
||||
|
||||
total_assets = latest_bs.get("Total Assets")
|
||||
if not total_assets or total_assets == 0:
|
||||
return None
|
||||
|
||||
total_liabilities = latest_bs.get(
|
||||
"Total Liabilities Net Minority Interest", latest_bs.get("Total Liabilities")
|
||||
)
|
||||
current_assets = latest_bs.get("Current Assets", 0)
|
||||
current_liabilities = latest_bs.get("Current Liabilities", 0)
|
||||
retained_earnings = latest_bs.get("Retained Earnings", 0)
|
||||
|
||||
ebit = latest_inc.get("EBIT")
|
||||
if pd.isna(ebit):
|
||||
ebit = latest_inc.get("Operating Income", 0)
|
||||
|
||||
sales = latest_inc.get("Total Revenue", 0)
|
||||
|
||||
market_cap = info.get("marketCap", 0)
|
||||
|
||||
# Handle NaNs
|
||||
total_liabilities = 0 if pd.isna(total_liabilities) else total_liabilities
|
||||
retained_earnings = 0 if pd.isna(retained_earnings) else retained_earnings
|
||||
|
||||
working_capital = current_assets - current_liabilities
|
||||
|
||||
x1 = working_capital / total_assets
|
||||
x2 = retained_earnings / total_assets
|
||||
x3 = ebit / total_assets
|
||||
x4 = market_cap / total_liabilities if total_liabilities > 0 else 0
|
||||
x5 = sales / total_assets
|
||||
|
||||
z_score = 1.2 * x1 + 1.4 * x2 + 3.3 * x3 + 0.6 * x4 + 1.0 * x5
|
||||
return round(z_score, 2)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error calculating Altman Z-Score for {ticker}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def calculate_piotroski_f_score(ticker: str) -> int:
|
||||
"""
|
||||
Calculate the Piotroski F-Score (0-9).
|
||||
High score (7-9) indicates strong value/health.
|
||||
Low score (0-3) indicates poor financial health.
|
||||
"""
|
||||
try:
|
||||
with suppress_yfinance_warnings():
|
||||
stock = yf.Ticker(ticker.upper())
|
||||
bs = stock.balance_sheet
|
||||
inc = stock.financials
|
||||
cf = stock.cashflow
|
||||
|
||||
if (
|
||||
bs.empty
|
||||
or inc.empty
|
||||
or cf.empty
|
||||
or len(bs.columns) < 2
|
||||
or len(inc.columns) < 2
|
||||
or len(cf.columns) < 1
|
||||
):
|
||||
return None
|
||||
|
||||
latest_bs = bs.iloc[:, 0]
|
||||
prev_bs = bs.iloc[:, 1]
|
||||
|
||||
latest_inc = inc.iloc[:, 0]
|
||||
prev_inc = inc.iloc[:, 1]
|
||||
|
||||
latest_cf = cf.iloc[:, 0]
|
||||
|
||||
total_assets = latest_bs.get("Total Assets")
|
||||
prev_total_assets = prev_bs.get("Total Assets")
|
||||
|
||||
if not total_assets or not prev_total_assets or total_assets == 0 or prev_total_assets == 0:
|
||||
return None
|
||||
|
||||
score = 0
|
||||
|
||||
# Profitability
|
||||
# 1. ROA > 0
|
||||
net_income = latest_inc.get("Net Income", 0)
|
||||
roa = net_income / total_assets
|
||||
if roa > 0:
|
||||
score += 1
|
||||
|
||||
# 2. Operating Cash Flow > 0
|
||||
cfo = latest_cf.get("Operating Cash Flow", 0)
|
||||
if pd.isna(cfo):
|
||||
cfo = latest_cf.get("Total Cash From Operating Activities", 0)
|
||||
if cfo > 0:
|
||||
score += 1
|
||||
|
||||
# 3. Change in ROA > 0
|
||||
prev_net_income = prev_inc.get("Net Income", 0)
|
||||
prev_roa = prev_net_income / prev_total_assets
|
||||
if roa > prev_roa:
|
||||
score += 1
|
||||
|
||||
# 4. CFO > Net Income
|
||||
if cfo > net_income:
|
||||
score += 1
|
||||
|
||||
# Leverage, Liquidity
|
||||
# 5. Change in Leverage (Long-Term Debt / Assets) < 0
|
||||
ltd = latest_bs.get("Long Term Debt", 0)
|
||||
prev_ltd = prev_bs.get("Long Term Debt", 0)
|
||||
if (ltd / total_assets) < (prev_ltd / prev_total_assets):
|
||||
score += 1
|
||||
|
||||
# 6. Change in Current Ratio > 0
|
||||
ca = latest_bs.get("Current Assets", 0)
|
||||
cl = latest_bs.get("Current Liabilities", 1) # avoid div by zero
|
||||
prev_ca = prev_bs.get("Current Assets", 0)
|
||||
prev_cl = prev_bs.get("Current Liabilities", 1)
|
||||
cr = ca / cl
|
||||
prev_cr = prev_ca / prev_cl
|
||||
if cr > prev_cr:
|
||||
score += 1
|
||||
|
||||
# 7. Change in Shares Outstanding < 0 (or constant)
|
||||
shares = latest_bs.get("Ordinary Shares Number", 0)
|
||||
prev_shares = prev_bs.get("Ordinary Shares Number", 0)
|
||||
if shares <= prev_shares:
|
||||
score += 1
|
||||
|
||||
# Operating Efficiency
|
||||
# 8. Change in Gross Margin > 0
|
||||
gp = latest_inc.get("Gross Profit", 0)
|
||||
prev_gp = prev_inc.get("Gross Profit", 0)
|
||||
rev = latest_inc.get("Total Revenue", 1)
|
||||
prev_rev = prev_inc.get("Total Revenue", 1)
|
||||
if (gp / rev) > (prev_gp / prev_rev):
|
||||
score += 1
|
||||
|
||||
# 9. Change in Asset Turnover > 0
|
||||
ato = rev / total_assets
|
||||
prev_ato = prev_rev / prev_total_assets
|
||||
if ato > prev_ato:
|
||||
score += 1
|
||||
|
||||
return score
|
||||
except Exception as e:
|
||||
logger.debug(f"Error calculating Piotroski F-Score for {ticker}: {e}")
|
||||
return None
|
||||
|
|
@ -65,7 +65,7 @@ class MinerviniScanner(BaseScanner):
|
|||
self.sma_200_slope_days = self.scanner_config.get("sma_200_slope_days", 20)
|
||||
self.min_pct_off_low = self.scanner_config.get("min_pct_off_low", 30)
|
||||
self.max_pct_from_high = self.scanner_config.get("max_pct_from_high", 25)
|
||||
self.max_tickers = self.scanner_config.get("max_tickers", 200)
|
||||
self.max_tickers = self.scanner_config.get("max_tickers", 50)
|
||||
|
||||
def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if not self.is_enabled():
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ DEFAULT_CONFIG = {
|
|||
"sma_200_slope_days": 20, # Days back to check SMA200 slope
|
||||
"min_pct_off_low": 30, # Must be 30%+ above 52w low
|
||||
"max_pct_from_high": 25, # Must be within 25% of 52w high
|
||||
"max_tickers": 200, # Cap universe to keep download under scanner timeout
|
||||
"max_tickers": 50, # Cap universe to keep download under scanner timeout (~75s for 50 tickers x 1y)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue