Stock-Tool/tests/test_scoring.py

297 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 0100 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()