TradingAgents/tests/portfolio/test_candidate_prioritizer.py

187 lines
6.9 KiB
Python

"""Tests for tradingagents/portfolio/candidate_prioritizer.py.
All pure Python — no mocks, no DB, no network calls.
Run::
pytest tests/portfolio/test_candidate_prioritizer.py -v
"""
from __future__ import annotations
import pytest
from tradingagents.portfolio.models import Holding, Portfolio
from tradingagents.portfolio.candidate_prioritizer import (
prioritize_candidates,
score_candidate,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_holding(ticker, shares=10.0, avg_cost=100.0, sector="Technology", current_value=None):
h = Holding(
holding_id="h-" + ticker,
portfolio_id="p1",
ticker=ticker,
shares=shares,
avg_cost=avg_cost,
sector=sector,
)
h.current_value = current_value or shares * avg_cost
return h
def _make_portfolio(cash=50_000.0, total_value=100_000.0):
p = Portfolio(
portfolio_id="p1",
name="Test",
cash=cash,
initial_cash=100_000.0,
)
p.total_value = total_value
p.equity_value = total_value - cash
p.cash_pct = cash / total_value
return p
_DEFAULT_CONFIG = {
"max_positions": 15,
"max_position_pct": 0.15,
"max_sector_pct": 0.35,
"min_cash_pct": 0.05,
}
def _make_candidate(
ticker="AAPL",
conviction="high",
thesis_angle="growth",
sector="Healthcare",
):
return {
"ticker": ticker,
"conviction": conviction,
"thesis_angle": thesis_angle,
"sector": sector,
"rationale": "Strong fundamentals",
}
# ---------------------------------------------------------------------------
# score_candidate
# ---------------------------------------------------------------------------
def test_score_high_conviction_growth_new_sector():
"""high * growth * new_sector * not_held = 3*3*2*1 = 18."""
candidate = _make_candidate(conviction="high", thesis_angle="growth", sector="Healthcare")
portfolio = _make_portfolio(cash=50_000.0, total_value=100_000.0)
result = score_candidate(candidate, [], portfolio.total_value, _DEFAULT_CONFIG)
assert result == pytest.approx(18.0)
def test_score_already_held_penalty():
"""Penalty of 0.5 when ticker already in holdings."""
candidate = _make_candidate(ticker="AAPL", conviction="high", thesis_angle="growth", sector="Healthcare")
holdings = [_make_holding("AAPL", sector="Technology")]
portfolio = _make_portfolio(cash=50_000.0, total_value=100_000.0)
# score = 3 * 3 * 2 * 0.5 = 9
result = score_candidate(candidate, holdings, portfolio.total_value, _DEFAULT_CONFIG)
assert result == pytest.approx(9.0)
def test_score_zero_for_max_sector():
"""Sector at max exposure → diversification_factor = 0 → score = 0."""
# Make Technology = 40% of 100k → 40_000 value in Technology
h1 = _make_holding("AAPL", shares=200, avg_cost=100, sector="Technology", current_value=20_000)
h2 = _make_holding("MSFT", shares=200, avg_cost=100, sector="Technology", current_value=20_000)
candidate = _make_candidate(conviction="high", thesis_angle="growth", sector="Technology")
result = score_candidate(candidate, [h1, h2], 100_000.0, _DEFAULT_CONFIG)
assert result == pytest.approx(0.0)
def test_score_low_conviction_defensive():
"""low * defensive * new_sector * not_held = 1*1*2*1 = 2."""
candidate = _make_candidate(conviction="low", thesis_angle="defensive", sector="Utilities")
result = score_candidate(candidate, [], 100_000.0, _DEFAULT_CONFIG)
assert result == pytest.approx(2.0)
def test_score_medium_momentum_existing_sector_under_70pct():
"""medium * momentum * under_70pct_of_max * not_held = 2*2.5*1*1 = 5."""
# Technology at 10% of 100k → under 70% of 35% max (24.5%)
h = _make_holding("AAPL", shares=100, avg_cost=100, sector="Technology", current_value=10_000)
# Use a different ticker so it's not already held
candidate = _make_candidate("GOOG", conviction="medium", thesis_angle="momentum", sector="Technology")
result = score_candidate(candidate, [h], 100_000.0, _DEFAULT_CONFIG)
assert result == pytest.approx(5.0)
# ---------------------------------------------------------------------------
# prioritize_candidates
# ---------------------------------------------------------------------------
def test_prioritize_candidates_sorted():
"""Results are sorted by priority_score descending."""
candidates = [
_make_candidate("LOW", conviction="low", thesis_angle="defensive", sector="Utilities"),
_make_candidate("HIGH", conviction="high", thesis_angle="growth", sector="Healthcare"),
_make_candidate("MED", conviction="medium", thesis_angle="value", sector="Financials"),
]
portfolio = _make_portfolio()
result = prioritize_candidates(candidates, portfolio, [], _DEFAULT_CONFIG)
scores = [r["priority_score"] for r in result]
assert scores == sorted(scores, reverse=True)
def test_prioritize_candidates_top_n():
"""top_n=2 returns only 2 candidates."""
candidates = [
_make_candidate("A", conviction="high", thesis_angle="growth", sector="Healthcare"),
_make_candidate("B", conviction="medium", thesis_angle="value", sector="Financials"),
_make_candidate("C", conviction="low", thesis_angle="defensive", sector="Utilities"),
]
portfolio = _make_portfolio()
result = prioritize_candidates(candidates, portfolio, [], _DEFAULT_CONFIG, top_n=2)
assert len(result) == 2
def test_prioritize_candidates_empty():
"""Empty candidates list → empty result."""
portfolio = _make_portfolio()
result = prioritize_candidates([], portfolio, [], _DEFAULT_CONFIG)
assert result == []
def test_prioritize_candidates_adds_priority_score():
"""Every returned candidate has a priority_score field."""
candidates = [
_make_candidate("AAPL", conviction="high", thesis_angle="growth", sector="Technology"),
]
portfolio = _make_portfolio()
result = prioritize_candidates(candidates, portfolio, [], _DEFAULT_CONFIG)
assert len(result) == 1
assert "priority_score" in result[0]
assert isinstance(result[0]["priority_score"], float)
def test_prioritize_candidates_skip_reason_for_zero_score():
"""Candidates with zero score (sector at max) receive a skip_reason."""
# Fill Technology to 40% → at max
h1 = _make_holding("AAPL", shares=200, avg_cost=100, sector="Technology", current_value=20_000)
h2 = _make_holding("MSFT", shares=200, avg_cost=100, sector="Technology", current_value=20_000)
candidates = [
_make_candidate("GOOG", conviction="high", thesis_angle="growth", sector="Technology"),
]
portfolio = _make_portfolio(cash=60_000.0, total_value=100_000.0)
result = prioritize_candidates(candidates, portfolio, [h1, h2], _DEFAULT_CONFIG)
assert len(result) == 1
assert result[0]["priority_score"] == pytest.approx(0.0)
assert "skip_reason" in result[0]