""" Fast scoring unit tests — no DB, no network, no GUI. Run automatically via PostToolUse hook on every edit to stock_screener.py. Tests cover: - Value score direction (cheap stocks score higher than expensive) - Negative equity → low quality score (not high) - Piotroski score feeds into quality correctly - Sentiment confidence weighting (1 headline ≠ 10 headlines) - All-None row doesn't crash any scoring function - All scores are within 0–100 bounds - Profile classification (early_stage, financial, high_growth, mature) """ import sys from pathlib import Path import pandas as pd import numpy as np import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "dist")) from stock_screener import ( compute_value_score, compute_growth_score, compute_quality_score, compute_profitability_score, compute_sentiment_score, compute_analyst_score, ) # --------------------------------------------------------------------------- # Mock sector stats — median/MAD pairs per metric for "Technology" sector. # Used so z-score functions return meaningful values instead of neutral 50. # --------------------------------------------------------------------------- MOCK_SS = { "Technology": { "pe_trailing": (25.0, 15.0), "pe_forward": (22.0, 12.0), "pb_ratio": (5.0, 3.0), "ev_ebitda": (18.0, 10.0), "ev_revenue": (8.0, 5.0), "revenue_growth": (0.12, 0.10), "earnings_growth": (0.15, 0.12), "eps_growth": (0.10, 0.15), "roa": (0.08, 0.06), "roe": (0.15, 0.10), "debt_to_equity": (0.50, 0.40), "current_ratio": (2.0, 0.8), "fcf_yield": (0.04, 0.03), "operating_margin": (0.15, 0.10), "profit_margin": (0.12, 0.08), "analyst_upside": (0.10, 0.15), } } def _base_row(**overrides): """Return a dict with all required fields set to safe defaults.""" defaults = dict( sector="Technology", pe_trailing=25.0, pe_forward=22.0, pb_ratio=5.0, ev_ebitda=18.0, ev_revenue=8.0, revenue_growth=0.12, earnings_growth=0.15, eps_trailing=3.0, eps_forward=3.5, roa=0.08, roe=0.15, debt_to_equity=0.50, current_ratio=2.0, piotroski_score=2, fcf_yield=0.04, operating_margin=0.15, profit_margin=0.12, accruals_ratio=-0.02, news_sentiment=0.0, news_headline_count=5, analyst_count=8, analyst_upside=0.10, analyst_norm=50.0, revenue=500_000_000, market_cap=5_000_000_000, short_percent=0.03, volatility=0.25, beta=1.0, ) defaults.update(overrides) return defaults def _df(*rows): """Build a DataFrame from _base_row dicts, indexed 0..n.""" return pd.DataFrame(list(rows)).reset_index(drop=True) # --------------------------------------------------------------------------- # Value score tests # --------------------------------------------------------------------------- class TestValueScore: def test_cheap_beats_expensive(self): df = _df( _base_row(pe_trailing=8, pe_forward=7, pb_ratio=1.5, ev_ebitda=6), # cheap _base_row(pe_trailing=120, pe_forward=100, pb_ratio=20, ev_ebitda=60), # expensive ) scores = compute_value_score(df, MOCK_SS) assert scores.iloc[0] > scores.iloc[1], ( f"Cheap stock ({scores.iloc[0]:.1f}) should beat expensive ({scores.iloc[1]:.1f})" ) def test_none_pe_doesnt_crash(self): df = _df(_base_row(pe_trailing=None, pe_forward=None)) scores = compute_value_score(df, MOCK_SS) assert 0 <= float(scores.iloc[0]) <= 100 def test_scores_in_bounds(self): rows = [ _base_row(pe_trailing=5), _base_row(pe_trailing=200), _base_row(pe_trailing=None), ] scores = compute_value_score(_df(*rows), MOCK_SS) assert scores.between(0, 100).all(), f"Out-of-bounds scores: {scores.tolist()}" # --------------------------------------------------------------------------- # Quality score tests # --------------------------------------------------------------------------- class TestQualityScore: def test_negative_equity_scores_low(self): """Negative D/E (negative book equity) must score low, not high.""" df = _df( _base_row(debt_to_equity=-2.0, roa=0.05), # negative equity _base_row(debt_to_equity=0.3, roa=0.12), # healthy balance sheet ) scores = compute_quality_score(df, MOCK_SS) assert scores.iloc[0] < 40, ( f"Negative equity should score < 40, got {scores.iloc[0]:.1f}" ) assert scores.iloc[1] > scores.iloc[0], ( f"Healthy balance sheet ({scores.iloc[1]:.1f}) should beat negative equity ({scores.iloc[0]:.1f})" ) def test_high_piotroski_beats_low(self): df = _df( _base_row(piotroski_score=4), # strong _base_row(piotroski_score=0), # weak ) scores = compute_quality_score(df, MOCK_SS) assert scores.iloc[0] > scores.iloc[1], ( f"Piotroski 4 ({scores.iloc[0]:.1f}) should beat Piotroski 0 ({scores.iloc[1]:.1f})" ) def test_high_de_scores_lower(self): df = _df( _base_row(debt_to_equity=0.1), # low leverage _base_row(debt_to_equity=5.0), # high leverage ) scores = compute_quality_score(df, MOCK_SS) assert scores.iloc[0] > scores.iloc[1], ( f"Low D/E ({scores.iloc[0]:.1f}) should beat high D/E ({scores.iloc[1]:.1f})" ) def test_scores_in_bounds(self): rows = [ _base_row(debt_to_equity=-5.0), _base_row(debt_to_equity=999.0), _base_row(roa=None, roe=None), ] scores = compute_quality_score(_df(*rows), MOCK_SS) assert scores.between(0, 100).all(), f"Out-of-bounds scores: {scores.tolist()}" # --------------------------------------------------------------------------- # Sentiment score tests # --------------------------------------------------------------------------- class TestSentimentScore: def test_confidence_weighting(self): """1 headline should blend toward neutral vs 10 headlines.""" df = _df( _base_row(news_sentiment=0.8, news_headline_count=1), # low confidence _base_row(news_sentiment=0.8, news_headline_count=10), # high confidence ) scores = compute_sentiment_score(df) assert scores.iloc[1] > scores.iloc[0], ( f"10 headlines ({scores.iloc[1]:.1f}) should outscore 1 headline ({scores.iloc[0]:.1f}) " f"for same positive sentiment" ) def test_single_headline_blends_toward_neutral(self): df = _df(_base_row(news_sentiment=0.8, news_headline_count=1)) score = float(compute_sentiment_score(df).iloc[0]) # 1 headline → confidence=0.2, so score should be closer to 50 than to 80+ assert score < 70, f"1-headline score should blend toward neutral, got {score:.1f}" def test_negative_sentiment_scores_low(self): df = _df( _base_row(news_sentiment=-0.7, news_headline_count=5), _base_row(news_sentiment=0.7, news_headline_count=5), ) scores = compute_sentiment_score(df) assert scores.iloc[0] < scores.iloc[1] def test_scores_in_bounds(self): rows = [ _base_row(news_sentiment=-1.0, news_headline_count=0), _base_row(news_sentiment=1.0, news_headline_count=20), _base_row(news_sentiment=None, news_headline_count=None), ] scores = compute_sentiment_score(_df(*rows)) assert scores.between(0, 100).all() # --------------------------------------------------------------------------- # Growth score tests # --------------------------------------------------------------------------- class TestGrowthScore: def test_high_growth_beats_low(self): df = _df( _base_row(revenue_growth=0.50, earnings_growth=0.60), _base_row(revenue_growth=-0.10, earnings_growth=-0.20), ) scores = compute_growth_score(df, MOCK_SS) assert scores.iloc[0] > scores.iloc[1] def test_scores_in_bounds(self): rows = [ _base_row(revenue_growth=None, earnings_growth=None), _base_row(revenue_growth=5.0, earnings_growth=5.0), _base_row(revenue_growth=-1.0, earnings_growth=-1.0), ] scores = compute_growth_score(_df(*rows), MOCK_SS) assert scores.between(0, 100).all() # --------------------------------------------------------------------------- # Profitability score tests # --------------------------------------------------------------------------- class TestProfitabilityScore: def test_cash_backed_earnings_score_higher(self): """Negative accruals (cash-backed earnings) should beat high accruals.""" df = _df( _base_row(accruals_ratio=-0.20, operating_margin=0.20), # cash-backed _base_row(accruals_ratio=0.20, operating_margin=0.20), # accrual-heavy ) scores = compute_profitability_score(df, MOCK_SS) assert scores.iloc[0] > scores.iloc[1] def test_scores_in_bounds(self): rows = [ _base_row(fcf_yield=None, operating_margin=None, profit_margin=None), _base_row(accruals_ratio=-0.5), _base_row(accruals_ratio=0.5), ] scores = compute_profitability_score(_df(*rows), MOCK_SS) assert scores.between(0, 100).all() # --------------------------------------------------------------------------- # All-None row doesn't crash any scorer # --------------------------------------------------------------------------- class TestNoneRobustness: NULL_ROW = dict( sector=None, pe_trailing=None, pe_forward=None, pb_ratio=None, ev_ebitda=None, ev_revenue=None, revenue_growth=None, earnings_growth=None, eps_trailing=None, eps_forward=None, roa=None, roe=None, debt_to_equity=None, current_ratio=None, piotroski_score=None, fcf_yield=None, operating_margin=None, profit_margin=None, accruals_ratio=None, news_sentiment=None, news_headline_count=None, analyst_count=None, analyst_upside=None, analyst_norm=None, revenue=None, market_cap=None, short_percent=None, volatility=None, beta=None, ) def test_value_no_crash(self): df = pd.DataFrame([self.NULL_ROW]) scores = compute_value_score(df, {}) assert scores.between(0, 100).all() def test_quality_no_crash(self): df = pd.DataFrame([self.NULL_ROW]) scores = compute_quality_score(df, {}) assert scores.between(0, 100).all() def test_growth_no_crash(self): df = pd.DataFrame([self.NULL_ROW]) scores = compute_growth_score(df, {}) assert scores.between(0, 100).all() def test_profitability_no_crash(self): df = pd.DataFrame([self.NULL_ROW]) scores = compute_profitability_score(df, {}) assert scores.between(0, 100).all() def test_sentiment_no_crash(self): df = pd.DataFrame([self.NULL_ROW]) scores = compute_sentiment_score(df) assert scores.between(0, 100).all()