"""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