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>
543 lines
23 KiB
Python
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
|