297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""
|
||
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()
|