284 lines
9.4 KiB
Python
284 lines
9.4 KiB
Python
"""Tests for recommendation worker — generating recommendations from trend data.
|
|
|
|
Tests the pure logic functions (no DB required). Async DB functions
|
|
are covered by integration tests.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
from services.recommendation.eligibility import evaluate_eligibility
|
|
from services.recommendation.worker import (
|
|
_extract_risk_classification,
|
|
build_recommendation,
|
|
build_thesis,
|
|
classify_risk,
|
|
)
|
|
from services.shared.schemas import (
|
|
ActionType,
|
|
RecommendationMode,
|
|
TrendDirection,
|
|
TrendSummary,
|
|
TrendWindow,
|
|
)
|
|
|
|
NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _make_summary(
|
|
ticker: str = "AAPL",
|
|
direction: TrendDirection = TrendDirection.BULLISH,
|
|
strength: float = 0.5,
|
|
confidence: float = 0.65,
|
|
contradiction: float = 0.1,
|
|
supporting: list[str] | None = None,
|
|
opposing: list[str] | None = None,
|
|
catalysts: list[str] | None = None,
|
|
risks: list[str] | None = None,
|
|
window: TrendWindow = TrendWindow.SEVEN_DAY,
|
|
) -> TrendSummary:
|
|
return TrendSummary(
|
|
entity_type="company",
|
|
entity_id=ticker,
|
|
window=window,
|
|
trend_direction=direction,
|
|
trend_strength=strength,
|
|
confidence=confidence,
|
|
top_supporting_evidence=supporting or ["doc1", "doc2", "doc3"],
|
|
top_opposing_evidence=opposing or [],
|
|
dominant_catalysts=catalysts or ["earnings"],
|
|
material_risks=risks or ["regulatory scrutiny"],
|
|
contradiction_score=contradiction,
|
|
generated_at=NOW,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_thesis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_thesis_contains_ticker_and_direction():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "AAPL" in thesis
|
|
assert "bullish" in thesis
|
|
|
|
|
|
def test_thesis_includes_catalysts():
|
|
summary = _make_summary(catalysts=["product", "m_and_a"])
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "product" in thesis
|
|
|
|
|
|
def test_thesis_includes_contradiction_note():
|
|
summary = _make_summary(contradiction=0.3)
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "disagreement" in thesis
|
|
|
|
|
|
def test_thesis_includes_risks():
|
|
summary = _make_summary(risks=["supply chain disruption"])
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "supply chain disruption" in thesis
|
|
|
|
|
|
def test_thesis_includes_evidence_counts():
|
|
summary = _make_summary(
|
|
supporting=["d1", "d2"],
|
|
opposing=["d3"],
|
|
)
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "2 supporting" in thesis
|
|
assert "1 opposing" in thesis
|
|
|
|
|
|
def test_thesis_includes_action():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
thesis = build_thesis(summary, result)
|
|
assert "BUY" in thesis
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# classify_risk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_risk_low_for_strong_signal():
|
|
summary = _make_summary(
|
|
confidence=0.8,
|
|
contradiction=0.05,
|
|
supporting=["d1", "d2", "d3", "d4", "d5"],
|
|
)
|
|
result = evaluate_eligibility(summary)
|
|
assert classify_risk(summary, result) == "low"
|
|
|
|
|
|
def test_risk_high_for_weak_signal():
|
|
summary = _make_summary(
|
|
confidence=0.36,
|
|
contradiction=0.55,
|
|
supporting=["d1"],
|
|
opposing=[],
|
|
)
|
|
result = evaluate_eligibility(summary)
|
|
risk = classify_risk(summary, result)
|
|
assert risk in ("high", "very_high")
|
|
|
|
|
|
def test_risk_moderate_for_mixed():
|
|
summary = _make_summary(
|
|
confidence=0.5,
|
|
contradiction=0.2,
|
|
supporting=["d1", "d2"],
|
|
opposing=["d3"],
|
|
)
|
|
result = evaluate_eligibility(summary)
|
|
assert classify_risk(summary, result) == "moderate"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_recommendation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_recommendation_basic():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result, reference_time=NOW)
|
|
|
|
assert rec.ticker == "AAPL"
|
|
assert rec.action == ActionType.BUY
|
|
assert rec.confidence == summary.confidence
|
|
assert rec.time_horizon == "swing_1d_10d"
|
|
assert rec.generated_at == NOW
|
|
assert len(rec.evidence_refs) == 3 # 3 supporting + 0 opposing
|
|
assert rec.model_metadata.provider == "deterministic"
|
|
|
|
|
|
def test_build_recommendation_includes_risk_in_thesis():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.thesis.startswith("[risk:")
|
|
|
|
|
|
def test_build_recommendation_with_llm_thesis():
|
|
"""When llm_thesis is provided, it replaces the deterministic body."""
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
llm_text = "Apple exhibits a bullish posture driven by strong earnings."
|
|
rec = build_recommendation(summary, result, llm_thesis=llm_text)
|
|
assert llm_text in rec.thesis
|
|
assert rec.thesis.startswith("[risk:")
|
|
assert rec.model_metadata.provider == "ollama"
|
|
assert rec.model_metadata.model_name == "thesis-rewrite"
|
|
|
|
|
|
def test_build_recommendation_without_llm_thesis_uses_deterministic():
|
|
"""When llm_thesis is None, the deterministic thesis is used."""
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.model_metadata.provider == "deterministic"
|
|
assert rec.model_metadata.model_name == "eligibility-v1"
|
|
|
|
|
|
def test_build_recommendation_combines_evidence():
|
|
summary = _make_summary(
|
|
supporting=["s1", "s2"],
|
|
opposing=["o1"],
|
|
)
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.evidence_refs == ["s1", "s2", "o1"]
|
|
|
|
|
|
def test_build_recommendation_position_sizing():
|
|
summary = _make_summary(confidence=0.7)
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.position_sizing.portfolio_pct == result.position_sizing.portfolio_pct
|
|
assert rec.position_sizing.max_loss_pct == result.position_sizing.max_loss_pct
|
|
|
|
|
|
def test_build_recommendation_invalidation_conditions():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert len(rec.invalidation_conditions) > 0
|
|
|
|
|
|
def test_build_recommendation_ineligible_is_informational():
|
|
"""When eligibility fails, mode should be informational (Req 7.4)."""
|
|
summary = _make_summary(confidence=0.2)
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.mode == RecommendationMode.INFORMATIONAL
|
|
|
|
|
|
def test_build_recommendation_sell_action():
|
|
summary = _make_summary(direction=TrendDirection.BEARISH, strength=0.5)
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result)
|
|
assert rec.action == ActionType.SELL
|
|
assert "SELL" in rec.thesis
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_risk_classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_risk_classification_from_thesis():
|
|
assert _extract_risk_classification("[risk:low] Some thesis text") == "low"
|
|
assert _extract_risk_classification("[risk:very_high] Bad signal") == "very_high"
|
|
|
|
|
|
def test_extract_risk_classification_missing_prefix():
|
|
assert _extract_risk_classification("No risk prefix here") == "moderate"
|
|
|
|
|
|
def test_extract_risk_classification_empty():
|
|
assert _extract_risk_classification("") == "moderate"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_recommendation stores full model metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_recommendation_model_metadata_deterministic():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result, reference_time=NOW)
|
|
assert rec.model_metadata.provider == "deterministic"
|
|
assert rec.model_metadata.model_name == "eligibility-v1"
|
|
assert rec.model_metadata.schema_version == "1.0.0"
|
|
|
|
|
|
def test_build_recommendation_model_metadata_llm():
|
|
summary = _make_summary()
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(
|
|
summary, result, reference_time=NOW,
|
|
llm_thesis="Rewritten thesis text.",
|
|
)
|
|
assert rec.model_metadata.provider == "ollama"
|
|
assert rec.model_metadata.model_name == "thesis-rewrite"
|
|
assert rec.model_metadata.prompt_version != ""
|
|
|
|
|
|
def test_build_recommendation_risk_classification_in_thesis():
|
|
"""The risk classification should be embedded in the thesis prefix."""
|
|
summary = _make_summary(confidence=0.8, contradiction=0.05,
|
|
supporting=["d1", "d2", "d3", "d4", "d5"])
|
|
result = evaluate_eligibility(summary)
|
|
rec = build_recommendation(summary, result, reference_time=NOW)
|
|
risk = _extract_risk_classification(rec.thesis)
|
|
assert risk == classify_risk(summary, result)
|