phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""Tests for deterministic recommendation eligibility logic."""
|
||||
from typing import Any
|
||||
|
||||
from services.recommendation.eligibility import (
|
||||
DEFAULT_ELIGIBILITY_CONFIG,
|
||||
EligibilityConfig,
|
||||
RejectionReason,
|
||||
evaluate_eligibility,
|
||||
)
|
||||
from services.shared.schemas import (
|
||||
ActionType,
|
||||
RecommendationMode,
|
||||
TrendDirection,
|
||||
TrendSummary,
|
||||
TrendWindow,
|
||||
)
|
||||
|
||||
|
||||
def _make_summary(**overrides: Any) -> TrendSummary:
|
||||
"""Build a TrendSummary with sensible defaults for testing."""
|
||||
defaults = dict(
|
||||
entity_type="company",
|
||||
entity_id="AAPL",
|
||||
window=TrendWindow.SEVEN_DAY,
|
||||
trend_direction=TrendDirection.BULLISH,
|
||||
trend_strength=0.5,
|
||||
confidence=0.6,
|
||||
top_supporting_evidence=["doc1", "doc2", "doc3"],
|
||||
top_opposing_evidence=[],
|
||||
dominant_catalysts=["earnings"],
|
||||
material_risks=["regulatory scrutiny"],
|
||||
contradiction_score=0.1,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return TrendSummary(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_eligible_strong_bullish():
|
||||
"""A strong bullish trend with good confidence passes all gates."""
|
||||
summary = _make_summary(
|
||||
trend_strength=0.5, confidence=0.6, contradiction_score=0.1,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is True
|
||||
assert result.rejection_reasons == []
|
||||
assert result.action == ActionType.BUY
|
||||
|
||||
|
||||
def test_rejected_low_confidence():
|
||||
"""Below min_confidence → rejected."""
|
||||
summary = _make_summary(confidence=0.2)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.LOW_CONFIDENCE in result.rejection_reasons
|
||||
|
||||
|
||||
def test_rejected_low_strength():
|
||||
"""Below min_trend_strength → rejected."""
|
||||
summary = _make_summary(trend_strength=0.05)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.LOW_TREND_STRENGTH in result.rejection_reasons
|
||||
|
||||
|
||||
def test_rejected_high_contradiction():
|
||||
"""Above max_contradiction_score → rejected."""
|
||||
summary = _make_summary(contradiction_score=0.7)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.HIGH_CONTRADICTION in result.rejection_reasons
|
||||
|
||||
|
||||
def test_rejected_insufficient_evidence():
|
||||
"""Too few evidence documents → rejected."""
|
||||
summary = _make_summary(
|
||||
top_supporting_evidence=["doc1"],
|
||||
top_opposing_evidence=[],
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.INSUFFICIENT_EVIDENCE in result.rejection_reasons
|
||||
|
||||
|
||||
def test_rejected_neutral_direction():
|
||||
"""Neutral trend direction → rejected."""
|
||||
summary = _make_summary(trend_direction=TrendDirection.NEUTRAL)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.NEUTRAL_DIRECTION in result.rejection_reasons
|
||||
|
||||
|
||||
def test_rejected_forces_informational_mode():
|
||||
"""Any rejection forces mode to informational (Req 7.4)."""
|
||||
summary = _make_summary(confidence=0.2)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.eligible is False
|
||||
assert result.mode == RecommendationMode.INFORMATIONAL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_action_buy_strong_bullish():
|
||||
summary = _make_summary(
|
||||
trend_direction=TrendDirection.BULLISH, trend_strength=0.4,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.BUY
|
||||
|
||||
|
||||
def test_action_sell_strong_bearish():
|
||||
summary = _make_summary(
|
||||
trend_direction=TrendDirection.BEARISH, trend_strength=0.4,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.SELL
|
||||
|
||||
|
||||
def test_action_hold_weak_bullish_decent_confidence():
|
||||
"""Weak bullish with decent confidence → HOLD."""
|
||||
summary = _make_summary(
|
||||
trend_direction=TrendDirection.BULLISH,
|
||||
trend_strength=0.15,
|
||||
confidence=0.55,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.HOLD
|
||||
|
||||
|
||||
def test_action_watch_weak_bullish_low_confidence():
|
||||
"""Weak bullish with low confidence → WATCH."""
|
||||
summary = _make_summary(
|
||||
trend_direction=TrendDirection.BULLISH,
|
||||
trend_strength=0.15,
|
||||
confidence=0.40,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.WATCH
|
||||
|
||||
|
||||
def test_action_watch_mixed():
|
||||
summary = _make_summary(trend_direction=TrendDirection.MIXED)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.WATCH
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mode_informational_for_hold():
|
||||
"""HOLD actions are always informational."""
|
||||
summary = _make_summary(
|
||||
trend_direction=TrendDirection.BULLISH,
|
||||
trend_strength=0.15,
|
||||
confidence=0.55,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.HOLD
|
||||
assert result.mode == RecommendationMode.INFORMATIONAL
|
||||
|
||||
|
||||
def test_mode_paper_eligible():
|
||||
"""BUY with confidence >= paper threshold → paper_eligible."""
|
||||
summary = _make_summary(
|
||||
trend_strength=0.4, confidence=0.55, contradiction_score=0.1,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.BUY
|
||||
assert result.mode == RecommendationMode.PAPER_ELIGIBLE
|
||||
|
||||
|
||||
def test_mode_live_eligible():
|
||||
"""BUY with high confidence, low contradiction, enough evidence → live_eligible."""
|
||||
summary = _make_summary(
|
||||
trend_strength=0.5,
|
||||
confidence=0.75,
|
||||
contradiction_score=0.1,
|
||||
top_supporting_evidence=["d1", "d2", "d3", "d4"],
|
||||
top_opposing_evidence=["d5"],
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.BUY
|
||||
assert result.mode == RecommendationMode.LIVE_ELIGIBLE
|
||||
|
||||
|
||||
def test_mode_not_live_high_contradiction():
|
||||
"""High contradiction blocks live even with high confidence."""
|
||||
summary = _make_summary(
|
||||
trend_strength=0.5,
|
||||
confidence=0.75,
|
||||
contradiction_score=0.4,
|
||||
top_supporting_evidence=["d1", "d2", "d3", "d4", "d5"],
|
||||
top_opposing_evidence=[],
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.mode != RecommendationMode.LIVE_ELIGIBLE
|
||||
|
||||
|
||||
def test_mode_informational_low_confidence_buy():
|
||||
"""BUY with confidence below paper threshold → informational."""
|
||||
summary = _make_summary(
|
||||
trend_strength=0.4, confidence=0.40,
|
||||
)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.action == ActionType.BUY
|
||||
assert result.mode == RecommendationMode.INFORMATIONAL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position sizing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_position_sizing_scales_with_confidence():
|
||||
"""Higher confidence → larger portfolio allocation."""
|
||||
low = _make_summary(confidence=0.40, trend_strength=0.4)
|
||||
high = _make_summary(confidence=0.80, trend_strength=0.4)
|
||||
r_low = evaluate_eligibility(low)
|
||||
r_high = evaluate_eligibility(high)
|
||||
assert r_high.position_sizing.portfolio_pct > r_low.position_sizing.portfolio_pct
|
||||
|
||||
|
||||
def test_position_sizing_penalised_by_contradiction():
|
||||
"""Higher contradiction → smaller portfolio allocation."""
|
||||
clean = _make_summary(contradiction_score=0.05, trend_strength=0.4)
|
||||
messy = _make_summary(contradiction_score=0.50, trend_strength=0.4)
|
||||
r_clean = evaluate_eligibility(clean)
|
||||
r_messy = evaluate_eligibility(messy)
|
||||
assert r_clean.position_sizing.portfolio_pct > r_messy.position_sizing.portfolio_pct
|
||||
|
||||
|
||||
def test_position_sizing_within_bounds():
|
||||
"""Sizing should always stay within configured bounds."""
|
||||
cfg = DEFAULT_ELIGIBILITY_CONFIG
|
||||
for conf in [0.35, 0.5, 0.7, 0.9]:
|
||||
for contra in [0.0, 0.3, 0.55]:
|
||||
summary = _make_summary(confidence=conf, contradiction_score=contra, trend_strength=0.4)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.position_sizing.portfolio_pct >= cfg.base_portfolio_pct * 0.5
|
||||
assert result.position_sizing.portfolio_pct <= cfg.max_portfolio_pct
|
||||
assert result.position_sizing.max_loss_pct >= cfg.base_max_loss_pct * 0.5
|
||||
assert result.position_sizing.max_loss_pct <= cfg.max_max_loss_pct
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time horizon and invalidation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_time_horizon_mapped():
|
||||
summary = _make_summary(window=TrendWindow.SEVEN_DAY)
|
||||
result = evaluate_eligibility(summary)
|
||||
assert result.time_horizon == "swing_1d_10d"
|
||||
|
||||
|
||||
def test_invalidation_conditions_present():
|
||||
summary = _make_summary()
|
||||
result = evaluate_eligibility(summary)
|
||||
assert len(result.invalidation_conditions) > 0
|
||||
assert any("AAPL" in c for c in result.invalidation_conditions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_custom_config_stricter_gates():
|
||||
"""A stricter config rejects what the default would accept."""
|
||||
strict = EligibilityConfig(min_confidence=0.80)
|
||||
summary = _make_summary(confidence=0.60)
|
||||
result = evaluate_eligibility(summary, config=strict)
|
||||
assert result.eligible is False
|
||||
assert RejectionReason.LOW_CONFIDENCE in result.rejection_reasons
|
||||
Reference in New Issue
Block a user