346 lines
12 KiB
Python
346 lines
12 KiB
Python
|
|
"""Tests for summarize helpers."""
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||
|
|
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
import summarize
|
||
|
|
from summarize import (
|
||
|
|
MoverContext,
|
||
|
|
SectorCluster,
|
||
|
|
WatchpointsData,
|
||
|
|
build_watchpoints_data,
|
||
|
|
classify_move_type,
|
||
|
|
detect_sector_clusters,
|
||
|
|
format_watchpoints,
|
||
|
|
get_index_change,
|
||
|
|
match_headline_to_symbol,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class FixedDateTime(datetime):
|
||
|
|
@classmethod
|
||
|
|
def now(cls, tz=None):
|
||
|
|
return cls(2026, 1, 1, 15, 0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_generate_briefing_auto_time_evening(capsys, monkeypatch):
|
||
|
|
def fake_market_news(*_args, **_kwargs):
|
||
|
|
return {
|
||
|
|
"headlines": [
|
||
|
|
{"source": "CNBC", "title": "Headline one", "link": "https://example.com/1"},
|
||
|
|
{"source": "Yahoo", "title": "Headline two", "link": "https://example.com/2"},
|
||
|
|
{"source": "CNBC", "title": "Headline three", "link": "https://example.com/3"},
|
||
|
|
],
|
||
|
|
"markets": {
|
||
|
|
"us": {
|
||
|
|
"name": "US Markets",
|
||
|
|
"indices": {
|
||
|
|
"^GSPC": {"name": "S&P 500", "data": {"price": 100, "change_percent": 1.0}},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
def fake_summary(*_args, **_kwargs):
|
||
|
|
return "OK"
|
||
|
|
|
||
|
|
monkeypatch.setattr(summarize, "get_market_news", fake_market_news)
|
||
|
|
monkeypatch.setattr(summarize, "get_portfolio_news", lambda *_a, **_k: None)
|
||
|
|
monkeypatch.setattr(summarize, "summarize_with_claude", fake_summary)
|
||
|
|
monkeypatch.setattr(summarize, "datetime", FixedDateTime)
|
||
|
|
|
||
|
|
args = type(
|
||
|
|
"Args",
|
||
|
|
(),
|
||
|
|
{
|
||
|
|
"lang": "de",
|
||
|
|
"style": "briefing",
|
||
|
|
"time": None,
|
||
|
|
"model": "claude",
|
||
|
|
"json": False,
|
||
|
|
"research": False,
|
||
|
|
"deadline": None,
|
||
|
|
"fast": False,
|
||
|
|
"llm": False,
|
||
|
|
"debug": False,
|
||
|
|
},
|
||
|
|
)()
|
||
|
|
|
||
|
|
summarize.generate_briefing(args)
|
||
|
|
stdout = capsys.readouterr().out
|
||
|
|
assert "Börsen Abend-Briefing" in stdout
|
||
|
|
|
||
|
|
|
||
|
|
# --- Tests for watchpoints feature (Issue #92) ---
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetIndexChange:
|
||
|
|
def test_extracts_sp500_change(self):
|
||
|
|
market_data = {
|
||
|
|
"markets": {
|
||
|
|
"us": {
|
||
|
|
"indices": {
|
||
|
|
"^GSPC": {"data": {"change_percent": -1.5}}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assert get_index_change(market_data) == -1.5
|
||
|
|
|
||
|
|
def test_returns_zero_on_missing_data(self):
|
||
|
|
assert get_index_change({}) == 0.0
|
||
|
|
assert get_index_change({"markets": {}}) == 0.0
|
||
|
|
assert get_index_change({"markets": {"us": {}}}) == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
class TestMatchHeadlineToSymbol:
|
||
|
|
def test_exact_symbol_match_dollar(self):
|
||
|
|
headlines = [{"title": "Breaking: $NVDA surges on AI demand"}]
|
||
|
|
result = match_headline_to_symbol("NVDA", "NVIDIA Corporation", headlines)
|
||
|
|
assert result is not None
|
||
|
|
assert "NVDA" in result["title"]
|
||
|
|
|
||
|
|
def test_exact_symbol_match_parens(self):
|
||
|
|
headlines = [{"title": "Tesla (TSLA) reports record deliveries"}]
|
||
|
|
result = match_headline_to_symbol("TSLA", "Tesla Inc", headlines)
|
||
|
|
assert result is not None
|
||
|
|
|
||
|
|
def test_exact_symbol_match_word_boundary(self):
|
||
|
|
headlines = [{"title": "AAPL announces new product line"}]
|
||
|
|
result = match_headline_to_symbol("AAPL", "Apple Inc", headlines)
|
||
|
|
assert result is not None
|
||
|
|
|
||
|
|
def test_company_name_match(self):
|
||
|
|
headlines = [{"title": "Apple announces record iPhone sales"}]
|
||
|
|
result = match_headline_to_symbol("AAPL", "Apple Inc", headlines)
|
||
|
|
assert result is not None
|
||
|
|
|
||
|
|
def test_no_match_returns_none(self):
|
||
|
|
headlines = [{"title": "Fed raises interest rates"}]
|
||
|
|
result = match_headline_to_symbol("NVDA", "NVIDIA Corporation", headlines)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_avoids_partial_symbol_match(self):
|
||
|
|
# "APP" should not match "application"
|
||
|
|
headlines = [{"title": "New application launches today"}]
|
||
|
|
result = match_headline_to_symbol("APP", "AppLovin Corp", headlines)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_empty_headlines(self):
|
||
|
|
result = match_headline_to_symbol("NVDA", "NVIDIA", [])
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestDetectSectorClusters:
|
||
|
|
def test_detects_cluster_three_stocks_same_direction(self):
|
||
|
|
movers = [
|
||
|
|
{"symbol": "NVDA", "change_pct": -5.0},
|
||
|
|
{"symbol": "AMD", "change_pct": -4.0},
|
||
|
|
{"symbol": "INTC", "change_pct": -3.0},
|
||
|
|
]
|
||
|
|
portfolio_meta = {
|
||
|
|
"NVDA": {"category": "Tech"},
|
||
|
|
"AMD": {"category": "Tech"},
|
||
|
|
"INTC": {"category": "Tech"},
|
||
|
|
}
|
||
|
|
clusters = detect_sector_clusters(movers, portfolio_meta)
|
||
|
|
assert len(clusters) == 1
|
||
|
|
assert clusters[0].category == "Tech"
|
||
|
|
assert clusters[0].direction == "down"
|
||
|
|
assert len(clusters[0].stocks) == 3
|
||
|
|
|
||
|
|
def test_no_cluster_if_less_than_three(self):
|
||
|
|
movers = [
|
||
|
|
{"symbol": "NVDA", "change_pct": -5.0},
|
||
|
|
{"symbol": "AMD", "change_pct": -4.0},
|
||
|
|
]
|
||
|
|
portfolio_meta = {
|
||
|
|
"NVDA": {"category": "Tech"},
|
||
|
|
"AMD": {"category": "Tech"},
|
||
|
|
}
|
||
|
|
clusters = detect_sector_clusters(movers, portfolio_meta)
|
||
|
|
assert len(clusters) == 0
|
||
|
|
|
||
|
|
def test_no_cluster_if_mixed_direction(self):
|
||
|
|
movers = [
|
||
|
|
{"symbol": "NVDA", "change_pct": 5.0},
|
||
|
|
{"symbol": "AMD", "change_pct": -4.0},
|
||
|
|
{"symbol": "INTC", "change_pct": 3.0},
|
||
|
|
]
|
||
|
|
portfolio_meta = {
|
||
|
|
"NVDA": {"category": "Tech"},
|
||
|
|
"AMD": {"category": "Tech"},
|
||
|
|
"INTC": {"category": "Tech"},
|
||
|
|
}
|
||
|
|
clusters = detect_sector_clusters(movers, portfolio_meta)
|
||
|
|
assert len(clusters) == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestClassifyMoveType:
|
||
|
|
def test_earnings_with_keyword(self):
|
||
|
|
headline = {"title": "Company beats Q3 earnings expectations"}
|
||
|
|
result = classify_move_type(headline, False, 5.0, 0.1)
|
||
|
|
assert result == "earnings"
|
||
|
|
|
||
|
|
def test_sector_cluster(self):
|
||
|
|
result = classify_move_type(None, True, -3.0, -0.5)
|
||
|
|
assert result == "sector"
|
||
|
|
|
||
|
|
def test_market_wide(self):
|
||
|
|
result = classify_move_type(None, False, -2.0, -2.0)
|
||
|
|
assert result == "market_wide"
|
||
|
|
|
||
|
|
def test_company_specific_with_headline(self):
|
||
|
|
headline = {"title": "Company announces acquisition"}
|
||
|
|
result = classify_move_type(headline, False, 3.0, 0.1)
|
||
|
|
assert result == "company_specific"
|
||
|
|
|
||
|
|
def test_company_specific_large_move_no_headline(self):
|
||
|
|
result = classify_move_type(None, False, 8.0, 0.1)
|
||
|
|
assert result == "company_specific"
|
||
|
|
|
||
|
|
def test_unknown_small_move_no_context(self):
|
||
|
|
result = classify_move_type(None, False, 1.5, 0.2)
|
||
|
|
assert result == "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
class TestFormatWatchpoints:
|
||
|
|
def test_formats_sector_cluster(self):
|
||
|
|
cluster = SectorCluster(
|
||
|
|
category="Tech",
|
||
|
|
stocks=[
|
||
|
|
MoverContext("NVDA", -5.0, 100.0, "Tech", None, "sector", None),
|
||
|
|
MoverContext("AMD", -4.0, 80.0, "Tech", None, "sector", None),
|
||
|
|
MoverContext("INTC", -3.0, 30.0, "Tech", None, "sector", None),
|
||
|
|
],
|
||
|
|
avg_change=-4.0,
|
||
|
|
direction="down",
|
||
|
|
vs_index=-3.5,
|
||
|
|
)
|
||
|
|
data = WatchpointsData(
|
||
|
|
movers=[],
|
||
|
|
sector_clusters=[cluster],
|
||
|
|
index_change=-0.5,
|
||
|
|
market_wide=False,
|
||
|
|
)
|
||
|
|
result = format_watchpoints(data, "en", {})
|
||
|
|
assert "Tech" in result
|
||
|
|
assert "-4.0%" in result
|
||
|
|
assert "vs Index" in result
|
||
|
|
|
||
|
|
def test_formats_individual_mover_with_headline(self):
|
||
|
|
mover = MoverContext(
|
||
|
|
symbol="NVDA",
|
||
|
|
change_pct=5.0,
|
||
|
|
price=100.0,
|
||
|
|
category="Tech",
|
||
|
|
matched_headline={"title": "NVIDIA reports record revenue"},
|
||
|
|
move_type="company_specific",
|
||
|
|
vs_index=4.5,
|
||
|
|
)
|
||
|
|
data = WatchpointsData(
|
||
|
|
movers=[mover],
|
||
|
|
sector_clusters=[],
|
||
|
|
index_change=0.5,
|
||
|
|
market_wide=False,
|
||
|
|
)
|
||
|
|
result = format_watchpoints(data, "en", {})
|
||
|
|
assert "NVDA" in result
|
||
|
|
assert "+5.0%" in result
|
||
|
|
assert "record revenue" in result
|
||
|
|
|
||
|
|
def test_formats_market_wide_move_english(self):
|
||
|
|
data = WatchpointsData(
|
||
|
|
movers=[],
|
||
|
|
sector_clusters=[],
|
||
|
|
index_change=-2.0,
|
||
|
|
market_wide=True,
|
||
|
|
)
|
||
|
|
result = format_watchpoints(data, "en", {})
|
||
|
|
assert "Market-wide move" in result
|
||
|
|
assert "S&P 500 fell 2.0%" in result
|
||
|
|
|
||
|
|
def test_formats_market_wide_move_german(self):
|
||
|
|
data = WatchpointsData(
|
||
|
|
movers=[],
|
||
|
|
sector_clusters=[],
|
||
|
|
index_change=2.5,
|
||
|
|
market_wide=True,
|
||
|
|
)
|
||
|
|
result = format_watchpoints(data, "de", {})
|
||
|
|
assert "Breite Marktbewegung" in result
|
||
|
|
assert "stieg 2.5%" in result
|
||
|
|
|
||
|
|
def test_uses_label_fallbacks(self):
|
||
|
|
mover = MoverContext(
|
||
|
|
symbol="XYZ",
|
||
|
|
change_pct=1.5,
|
||
|
|
price=50.0,
|
||
|
|
category="Other",
|
||
|
|
matched_headline=None,
|
||
|
|
move_type="unknown",
|
||
|
|
vs_index=1.0,
|
||
|
|
)
|
||
|
|
data = WatchpointsData(
|
||
|
|
movers=[mover],
|
||
|
|
sector_clusters=[],
|
||
|
|
index_change=0.5,
|
||
|
|
market_wide=False,
|
||
|
|
)
|
||
|
|
labels = {"no_catalyst": " -- no news"}
|
||
|
|
result = format_watchpoints(data, "en", labels)
|
||
|
|
assert "XYZ" in result
|
||
|
|
assert "no news" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestBuildWatchpointsData:
|
||
|
|
def test_builds_complete_data_structure(self):
|
||
|
|
movers = [
|
||
|
|
{"symbol": "NVDA", "change_pct": -5.0, "price": 100.0},
|
||
|
|
{"symbol": "AMD", "change_pct": -4.0, "price": 80.0},
|
||
|
|
{"symbol": "INTC", "change_pct": -3.0, "price": 30.0},
|
||
|
|
{"symbol": "AAPL", "change_pct": 2.0, "price": 150.0},
|
||
|
|
]
|
||
|
|
headlines = [
|
||
|
|
{"title": "NVIDIA reports weak guidance"},
|
||
|
|
{"title": "Apple announces new product"},
|
||
|
|
]
|
||
|
|
portfolio_meta = {
|
||
|
|
"NVDA": {"category": "Tech", "name": "NVIDIA Corporation"},
|
||
|
|
"AMD": {"category": "Tech", "name": "Advanced Micro Devices"},
|
||
|
|
"INTC": {"category": "Tech", "name": "Intel Corporation"},
|
||
|
|
"AAPL": {"category": "Tech", "name": "Apple Inc"},
|
||
|
|
}
|
||
|
|
index_change = -0.5
|
||
|
|
|
||
|
|
result = build_watchpoints_data(movers, headlines, portfolio_meta, index_change)
|
||
|
|
|
||
|
|
# Should detect Tech sector cluster (3 losers)
|
||
|
|
assert len(result.sector_clusters) == 1
|
||
|
|
assert result.sector_clusters[0].category == "Tech"
|
||
|
|
assert result.sector_clusters[0].direction == "down"
|
||
|
|
|
||
|
|
# All movers should be present
|
||
|
|
assert len(result.movers) == 4
|
||
|
|
|
||
|
|
# NVDA should have matched headline
|
||
|
|
nvda_mover = next(m for m in result.movers if m.symbol == "NVDA")
|
||
|
|
assert nvda_mover.matched_headline is not None
|
||
|
|
assert "guidance" in nvda_mover.matched_headline["title"]
|
||
|
|
|
||
|
|
# vs_index should be calculated
|
||
|
|
assert nvda_mover.vs_index == -5.0 - (-0.5) # -4.5
|
||
|
|
|
||
|
|
def test_handles_empty_movers(self):
|
||
|
|
result = build_watchpoints_data([], [], {}, 0.0)
|
||
|
|
assert result.movers == []
|
||
|
|
assert result.sector_clusters == []
|
||
|
|
assert result.market_wide is False
|
||
|
|
|
||
|
|
def test_detects_market_wide_move(self):
|
||
|
|
result = build_watchpoints_data([], [], {}, -2.0)
|
||
|
|
assert result.market_wide is True
|