Files
US2KR/tests/test_pipeline.py
T
jsahnandClaude Fable 5 920d9358a0 Add SearXNG news collector merged into the briefing pipeline
Optional meta-search source (SEARXNG_BASE_URL): per-query isolation,
time_range=day, merged and deduplicated with the RSS feeds. Briefings
still publish on RSS alone if the instance is down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 00:58:35 +09:00

543 lines
23 KiB
Python

"""Pipeline tests: every collaborator is monkeypatched on us2kr.pipeline; no network, no real DB."""
from __future__ import annotations
from datetime import date, datetime
from zoneinfo import ZoneInfo
import pytest
from _orchestration_stubs import FakeClock, FakeDeliverer, FakeStore
from us2kr import pipeline
from us2kr.config import Settings
from us2kr.llm.base import LLMError
from us2kr.market_time import MarketClock
from us2kr.models import EarningsItem, EconEvent, NewsItem, Quote, Report
KST = ZoneInfo("Asia/Seoul")
ET = ZoneInfo("America/New_York")
SESSION = date(2026, 8, 25) # Tuesday
NOW = datetime(2026, 8, 26, 6, 15, tzinfo=KST)
def make_settings(tmp_path, **overrides) -> Settings:
values = {
"US2KR_DATA_DIR": str(tmp_path / "data"),
"US2KR_REPORTS_DIR": str(tmp_path / "reports"),
"US2KR_LOG_DIR": str(tmp_path / "logs"),
"MINIMAX_API_KEY": "sk-test-not-a-real-key",
}
values.update(overrides)
return Settings(_env_file=None, **values)
def sample_quotes() -> list[Quote]:
return [
Quote(
symbol="^GSPC", name="S&P 500", group="indices", close=6500.0, prev_close=6450.0, change_pct=0.78
),
Quote(symbol="NVDA", name="엔비디아", group="semis", close=180.0, prev_close=175.0, change_pct=2.86),
]
def sample_news() -> list[NewsItem]:
return [
NewsItem(
title="Stocks rally",
link="https://example.com/1",
source="CNBC",
published=datetime(2026, 8, 25, 17, 0, tzinfo=ET),
)
]
def sample_report() -> Report:
return Report(
headline="반도체 강세에 미 증시 상승",
market_overview="S&P 500이 0.8% 올랐다.",
key_drivers=["엔비디아 강세"],
rates_fx_commodities="10년물 금리 소폭 하락.",
korea_implications=["SOX 강세 -> 국내 반도체 우호적"],
watchlist_today=["삼성전자 실적"],
risk_flags=["환율 변동성"],
)
class Env:
"""Bundle of fakes wired into us2kr.pipeline for one test."""
def __init__(self, monkeypatch, tmp_path, **settings_overrides):
self.settings = make_settings(tmp_path, **settings_overrides)
self.clock = FakeClock(latest=SESSION)
self.store = FakeStore(notes=["S&P 500 3일 연속 상승"])
self.deliverer = FakeDeliverer("file")
self.deliverers = [self.deliverer]
self.calls: dict[str, int] = {}
self.summarize_payloads: list = []
def count(name):
self.calls[name] = self.calls.get(name, 0) + 1
def fetch_quotes(session, tickers=None):
count("prices")
return sample_quotes()
def fetch_news(since, max_items=40):
count("news")
assert since.tzinfo is not None
self.news_since = since
self.news_max_items = max_items
return sample_news()
def fetch_events(session, fred_api_key):
count("events")
return [EconEvent(name="CPI", actual="2.9%", kind="released")]
def fetch_earnings(session, tickers):
count("earnings")
self.earnings_tickers = tickers
return [EarningsItem(symbol="NVDA", name="엔비디아", report_date=session, timing="amc")]
def summarize_payload(payload, provider):
count("llm")
self.summarize_payloads.append(payload)
return sample_report(), "MiniMax-M3"
def render_markdown(payload, report, model_used):
count("render")
tag = "degraded" if report is None else report.headline
return f"# 브리핑 {payload.krx_date}\n{tag}\nmodel={model_used}\n"
monkeypatch.setattr(pipeline, "MarketClock", lambda offset, **_kw: self.clock)
monkeypatch.setattr(pipeline, "Store", lambda path: self.store)
monkeypatch.setattr(pipeline, "fetch_quotes", fetch_quotes)
monkeypatch.setattr(pipeline, "fetch_news", fetch_news)
monkeypatch.setattr(pipeline, "fetch_events", fetch_events)
monkeypatch.setattr(pipeline, "fetch_earnings", fetch_earnings)
monkeypatch.setattr(pipeline, "build_provider", lambda settings: object())
monkeypatch.setattr(pipeline, "summarize_payload", summarize_payload)
monkeypatch.setattr(pipeline, "render_markdown", render_markdown)
monkeypatch.setattr(pipeline, "build_deliverers", lambda settings: self.deliverers)
monkeypatch.setattr(pipeline, "_now", lambda: NOW)
self.monkeypatch = monkeypatch
@pytest.fixture
def env(monkeypatch, tmp_path):
return Env(monkeypatch, tmp_path)
# ------------------------------------------------------------------------------ build_payload
def test_build_payload_assembles_everything(env):
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert payload.session_date == SESSION
assert payload.us_close_time == datetime(2026, 8, 25, 16, 0, tzinfo=ET)
assert payload.krx_date == date(2026, 8, 26)
assert payload.krx_open_today is True
assert payload.generated_at == NOW
assert [q.symbol for q in payload.quotes] == ["^GSPC", "NVDA"]
assert len(payload.news) == 1 and len(payload.events) == 1 and len(payload.earnings) == 1
assert payload.history_notes == ["S&P 500 3일 연속 상승"]
assert payload.data_gaps == []
# news window derived from close time and settings
assert env.news_since == payload.us_close_time - __import__("datetime").timedelta(hours=18)
assert env.news_max_items == 40
# earnings tickers = semis + megacap + korea_linked
assert {"NVDA", "AAPL", "KB", "CPNG"} <= set(env.earnings_tickers)
assert "EWY" not in env.earnings_tickers # ETF: never reports earnings
assert "^GSPC" not in env.earnings_tickers
# persisted
assert env.store.quotes[SESSION] == payload.quotes
assert {name for (_, name) in env.store.raw} == {"prices", "news", "events", "earnings"}
assert '"^GSPC"' in env.store.raw[(SESSION, "prices")]
def test_build_payload_records_gap_and_continues_when_collector_raises(env):
def boom(since, max_items=40):
raise RuntimeError("feed down")
env.monkeypatch.setattr(pipeline, "fetch_news", boom)
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert payload.news == []
assert payload.data_gaps == ["news: RuntimeError: feed down"]
assert len(payload.quotes) == 2 # other collectors still ran
assert env.calls["events"] == 1 and env.calls["earnings"] == 1
def test_build_payload_flags_empty_quotes(env):
env.monkeypatch.setattr(pipeline, "fetch_quotes", lambda session, tickers=None: [])
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert payload.quotes == []
assert "prices: no quotes" in payload.data_gaps
assert SESSION not in env.store.quotes # nothing to save
def test_build_payload_does_not_duplicate_prices_gap_on_failure(env):
def boom(session, tickers=None):
raise ConnectionError("yahoo down")
env.monkeypatch.setattr(pipeline, "fetch_quotes", boom)
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
prices_gaps = [g for g in payload.data_gaps if g.startswith("prices:")]
assert len(prices_gaps) == 1 and "ConnectionError" in prices_gaps[0]
def test_build_payload_history_failure_is_a_gap(env):
env.store.fail_history = True
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert payload.history_notes == []
assert any(g.startswith("history:") for g in payload.data_gaps)
def test_build_payload_skips_searx_when_not_configured(env):
def boom(*args, **kwargs):
raise AssertionError("fetch_searx_news must not be called without SEARXNG_BASE_URL")
env.monkeypatch.setattr(pipeline, "fetch_searx_news", boom)
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert payload.data_gaps == []
def test_build_payload_merges_searx_news(monkeypatch, tmp_path):
env = Env(
monkeypatch,
tmp_path,
SEARXNG_BASE_URL="http://searx.local:8888",
US2KR_SEARXNG_QUERIES="markets, fed",
US2KR_NEWS_MAX_ITEMS="3",
)
rss = sample_news() # 1 item, published 17:00 ET
searx_items = [
NewsItem( # duplicate of the RSS story by link; same timestamp -> RSS copy wins the tie
title="Stocks rally (search copy)",
link="https://example.com/1#utm",
source="Reuters",
published=datetime(2026, 8, 25, 17, 0, tzinfo=ET),
),
NewsItem(
title="Chip stocks jump",
link="https://example.com/chips",
source="Barron's",
published=datetime(2026, 8, 25, 18, 30, tzinfo=ET),
),
]
def fetch_searx_news(base_url, since, queries=None, **kwargs):
env.calls["searxng"] = env.calls.get("searxng", 0) + 1
assert base_url == "http://searx.local:8888"
assert queries == ["markets", "fed"]
assert since == env.news_since
return searx_items
env.monkeypatch.setattr(pipeline, "fetch_searx_news", fetch_searx_news)
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert env.calls["searxng"] == 1
assert [n.title for n in payload.news] == ["Chip stocks jump", "Stocks rally"]
assert payload.data_gaps == []
# the archived news blob is the merged list
assert "Chip stocks jump" in env.store.raw[(SESSION, "news")]
assert rss[0].title in env.store.raw[(SESSION, "news")]
def test_build_payload_searx_failure_keeps_rss_news(monkeypatch, tmp_path):
env = Env(monkeypatch, tmp_path, SEARXNG_BASE_URL="http://searx.local:8888")
def boom(base_url, since, queries=None, **kwargs):
raise RuntimeError("all searxng queries failed: markets: HTTPError")
env.monkeypatch.setattr(pipeline, "fetch_searx_news", boom)
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
assert [n.title for n in payload.news] == ["Stocks rally"]
assert any(g.startswith("searxng: RuntimeError") for g in payload.data_gaps)
# -------------------------------------------------------------------------------- run_briefing
def test_run_briefing_happy_path_saves_then_delivers(env):
result = pipeline.run_briefing(SESSION, env.settings)
assert result.error is None and result.degraded is False
assert result.report is not None and result.model_used == "MiniMax-M3"
assert result.markdown.startswith("# 브리핑 2026-08-26")
assert env.store.has_report(date(2026, 8, 26))
assert env.store.saved_results == [result]
assert env.deliverer.delivered == [result]
assert env.store.sent == [(1, "file", "file-receipt-1")]
assert env.deliverer.errors == []
assert env.store.closed is True
assert env.summarize_payloads[0].session_date == SESSION
def test_run_briefing_llm_failure_degrades_and_notifies(env):
def failing(payload, provider):
raise LLMError("MiniMax-M3: 500; MiniMax-M2.7: timeout")
env.monkeypatch.setattr(pipeline, "summarize_payload", failing)
result = pipeline.run_briefing(SESSION, env.settings)
assert result.degraded is True and result.report is None and result.model_used is None
assert "MiniMax-M3: 500" in (result.error or "")
assert "degraded" in result.markdown
assert env.store.has_report(date(2026, 8, 26)) # degraded briefing is still recorded
assert env.deliverer.delivered == [result] # ...and delivered
assert env.deliverer.errors == [(pipeline.DEGRADED_SUBJECT, result.error)]
def test_run_briefing_unexpected_provider_error_also_degrades(env):
def bad_provider(settings):
raise ValueError("MINIMAX_API_KEY missing")
env.monkeypatch.setattr(pipeline, "build_provider", bad_provider)
result = pipeline.run_briefing(SESSION, env.settings)
assert result.degraded is True and "MINIMAX_API_KEY missing" in (result.error or "")
assert env.deliverer.delivered == [result]
def test_run_briefing_skips_when_already_sent(env):
env.store.reports[(date(2026, 8, 26), "briefing")] = 99
result = pipeline.run_briefing(SESSION, env.settings)
assert result.error == pipeline.SKIP_ALREADY_SENT
assert result.report is None and result.markdown == ""
assert result.payload.krx_date == date(2026, 8, 26)
assert env.calls == {} # nothing collected
assert env.deliverer.delivered == []
assert env.store.saved_results == []
assert env.store.closed is True
def test_run_briefing_force_reruns_even_if_sent(env):
env.store.reports[(date(2026, 8, 26), "briefing")] = 99
result = pipeline.run_briefing(SESSION, env.settings, force=True)
assert result.error is None
assert env.calls["prices"] == 1 and env.calls["llm"] == 1
assert env.deliverer.delivered == [result]
assert env.store.saved_results == [result]
def test_run_briefing_dry_run_prints_and_does_not_save_or_deliver(env, capsys):
result = pipeline.run_briefing(SESSION, env.settings, dry_run=True)
out = capsys.readouterr().out
assert result.markdown in out
assert env.store.saved_results == [] and env.store.sent == []
assert env.deliverer.delivered == []
assert result.report is not None
def test_run_briefing_dry_run_ignores_already_sent(env, capsys):
env.store.reports[(date(2026, 8, 26), "briefing")] = 99
result = pipeline.run_briefing(SESSION, env.settings, dry_run=True)
assert result.error is None
assert env.calls["prices"] == 1
assert "브리핑" in capsys.readouterr().out
def test_run_briefing_no_llm_is_degraded_without_error_notification(env):
result = pipeline.run_briefing(SESSION, env.settings, no_llm=True)
assert result.degraded is True and result.report is None and result.error is None
assert "llm" not in env.calls
assert env.deliverer.delivered == [result]
assert env.deliverer.errors == [] # deliberate no-LLM run is not an operator error
def test_run_briefing_krx_closed_skip_when_configured(monkeypatch, tmp_path):
env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true")
env.clock.krx_open = False
result = pipeline.run_briefing(SESSION, env.settings)
assert result.error == pipeline.SKIP_KRX_CLOSED
assert result.payload.krx_open_today is False
assert env.calls == {} and env.deliverer.delivered == []
def test_run_briefing_krx_closed_still_runs_by_default(env):
env.clock.krx_open = False
result = pipeline.run_briefing(SESSION, env.settings)
assert result.error is None
assert result.payload.krx_open_today is False
assert env.deliverer.delivered == [result]
def test_run_briefing_uses_latest_completed_session_when_none(env):
env.clock.latest = date(2026, 8, 24)
result = pipeline.run_briefing(None, env.settings)
assert result.payload.session_date == date(2026, 8, 24)
assert result.payload.krx_date == date(2026, 8, 25)
assert env.clock.calls[0][0] == "latest_completed_session"
def test_run_briefing_rejects_non_session_date(env):
with pytest.raises(ValueError, match="not a US trading session"):
pipeline.run_briefing(date(2026, 8, 23), env.settings) # Sunday
assert env.store.closed is True
def test_run_briefing_one_failing_deliverer_does_not_block_others(env):
broken = FakeDeliverer("telegram", fail=True)
env.deliverers.append(broken)
result = pipeline.run_briefing(SESSION, env.settings)
assert env.deliverer.delivered == [result]
assert broken.delivered == []
assert env.store.sent == [(1, "file", "file-receipt-1")]
assert env.store.has_report(date(2026, 8, 26))
def test_run_briefing_saves_report_before_delivery(env):
order: list[str] = []
original_save = env.store.save_report
def save_report(result, kind="briefing"):
order.append("save")
return original_save(result, kind)
class OrderedDeliverer(FakeDeliverer):
def deliver(self, result):
order.append("deliver")
return super().deliver(result)
env.store.save_report = save_report
env.deliverers[:] = [OrderedDeliverer("file")]
pipeline.run_briefing(SESSION, env.settings)
assert order == ["save", "deliver"]
def test_earnings_tickers_come_from_expected_groups():
tickers = pipeline.earnings_tickers()
assert tickers[0] == "NVDA"
assert "TSLA" in tickers and "KB" in tickers
assert "^VIX" not in tickers and "ES=F" not in tickers
assert "EWY" not in tickers
assert len(tickers) == len(set(tickers))
def test_run_briefing_warns_when_session_not_closed_yet(env, caplog):
env.monkeypatch.setattr(
pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST)
) # before close
with caplog.at_level("WARNING", logger="us2kr.pipeline"):
result = pipeline.run_briefing(SESSION, env.settings, dry_run=True)
assert result.error is None
assert "has not closed yet" in caplog.text
# ------------------------------------------------------------------------ not-yet-closed session
def test_run_briefing_refuses_to_persist_session_not_closed_yet(env):
env.monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST))
with pytest.raises(ValueError, match="has not closed yet"):
pipeline.run_briefing(SESSION, env.settings, no_llm=True)
assert env.calls == {} # nothing collected, nothing saved
assert not env.store.has_report(date(2026, 8, 26)) and env.store.quotes == {}
assert env.deliverer.delivered == []
assert env.store.closed is True
def test_run_briefing_force_does_not_bypass_not_closed_guard(env):
env.monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST))
with pytest.raises(ValueError, match="has not closed yet"):
pipeline.run_briefing(SESSION, env.settings, force=True, no_llm=True)
assert not env.store.has_report(date(2026, 8, 26))
# ------------------------------------------------------------------------ delivery failures
def test_run_briefing_delivery_failure_is_reported_and_alerted(env):
broken = FakeDeliverer("telegram", fail=True)
env.deliverers.append(broken)
result = pipeline.run_briefing(SESSION, env.settings)
assert pipeline.delivery_failed(result)
assert result.error.startswith("delivery failed: telegram: RuntimeError: telegram is down")
assert result.degraded is False # the briefing itself was fine
assert env.deliverer.delivered == [result] and broken.delivered == []
assert env.store.sent == [(1, "file", "file-receipt-1")]
assert env.store.has_report(date(2026, 8, 26)) # stays recorded; resend via --force
# alert through every channel: the file channel writes errors.log, the broken one is tried too
assert [s for s, _ in env.deliverer.errors] == [pipeline.DELIVERY_FAILED_SUBJECT]
detail = env.deliverer.errors[0][1]
assert "telegram" in detail and "2026-08-26" in detail and "run-once --force --date 2026-08-25" in detail
assert broken.errors == env.deliverer.errors
def test_run_briefing_degraded_plus_delivery_failure_sends_both_alerts(env):
def failing(payload, provider):
raise LLMError("MiniMax-M3: 500")
env.monkeypatch.setattr(pipeline, "summarize_payload", failing)
env.deliverers.append(FakeDeliverer("telegram", fail=True))
result = pipeline.run_briefing(SESSION, env.settings)
assert result.degraded and pipeline.delivery_failed(result)
assert result.error.startswith("MiniMax-M3: 500 | delivery failed: telegram")
subjects = [s for s, _ in env.deliverer.errors]
assert subjects == [pipeline.DEGRADED_SUBJECT, pipeline.DELIVERY_FAILED_SUBJECT]
assert env.deliverer.errors[0][1] == "MiniMax-M3: 500" # degraded alert carries only the LLM error
def test_delivery_failed_helper(env):
result = pipeline.run_briefing(SESSION, env.settings)
assert not pipeline.delivery_failed(result)
result.error = "LLM failed"
assert not pipeline.delivery_failed(result)
# ------------------------------------------------------------------------ skip_when_krx_closed semantics
def _real_clock(env):
clock = MarketClock(env.settings.run_offset_minutes, anchor=date(2026, 8, 25))
env.monkeypatch.setattr(pipeline, "MarketClock", lambda offset, **_kw: clock)
return clock
def test_friday_session_is_delivered_even_when_skip_flag_set(monkeypatch, tmp_path):
env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true")
_real_clock(env)
monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 22, 6, 15, tzinfo=KST))
result = pipeline.run_briefing(date(2026, 8, 21), env.settings) # Fri -> Sat krx_date
assert result.error is None
assert result.payload.krx_date == date(2026, 8, 22) and result.payload.krx_open_today is False
assert env.calls["prices"] == 1 and env.deliverer.delivered == [result]
def test_early_close_before_krx_holiday_weekend_is_delivered_when_flag_set(monkeypatch, tmp_path):
# Thu 12-24 (13:00 ET early close) -> Fri 12-25 (KRX closed); next US = next KRX = Mon 12-28
env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true")
_real_clock(env)
monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 12, 25, 4, 15, tzinfo=KST))
result = pipeline.run_briefing(date(2026, 12, 24), env.settings)
assert result.error is None and result.payload.krx_open_today is False
assert env.deliverer.delivered == [result]
def test_chuseok_superseded_session_is_skipped_when_flag_set(monkeypatch, tmp_path):
# Wed 09-23 -> Thu 09-24 (추석): US trades again Thu 09-24 before KRX reopens Mon 09-28
env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true")
_real_clock(env)
monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 9, 24, 6, 15, tzinfo=KST))
result = pipeline.run_briefing(date(2026, 9, 23), env.settings)
assert result.error == pipeline.SKIP_KRX_CLOSED
assert env.calls == {} and env.deliverer.delivered == []
# ...but the last US session before KRX reopens (Fri 09-25 -> Sat 09-26) is delivered
monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 9, 26, 6, 15, tzinfo=KST))
result = pipeline.run_briefing(date(2026, 9, 25), env.settings)
assert result.error is None and env.deliverer.delivered == [result]
def test_superseded_helper_with_real_clock():
clock = MarketClock(75, anchor=date(2026, 8, 25))
cases = {
date(2026, 8, 21): False, # Fri -> Sat: Monday KRX needs it
date(2026, 12, 24): False, # early close -> Fri 12-25 holiday; next US = next KRX = Mon 12-28
date(2026, 9, 23): True, # Chuseok run: US 09-24 closes before KRX reopens 09-28
date(2026, 10, 8): True, # 한글날 Fri 10-09: US 10-09 closes before KRX Mon 10-12
}
for session, expected in cases.items():
krx_date = clock.krx_date_for(session)
assert clock.is_krx_session(krx_date) is False
assert pipeline._superseded_before_krx_open(session, krx_date, clock) is expected, session