No US session closes into Monday KST, so Mondays (default 07:00, cron in the daemon with restart catch-up) now publish a weekly review built entirely from stored data: the quotes table collapsed into week-over- week changes and the week's daily briefing headlines as the narrative. The recap is a BriefingResult kind="weekly" reusing MarketPayload, so the store, verification pass, all delivery channels (Blogger title "미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work unchanged. Manual runs via `run-once --weekly [--date <monday>]`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
7.6 KiB
Python
210 lines
7.6 KiB
Python
"""Install placeholder modules for collaborators that other agents have not written yet.
|
|
|
|
Only modules whose *file does not exist* are stubbed, so once the real modules land these helpers
|
|
are no-ops and the orchestration tests exercise the real imports (still monkeypatched per test).
|
|
Test modules import this helper first (isort puts it before the first-party ``us2kr`` block).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
from datetime import date, datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
_STUBS: dict[str, list[str]] = {
|
|
"us2kr.market_time": ["MarketClock"],
|
|
"us2kr.collectors.prices": ["fetch_quotes"],
|
|
"us2kr.collectors.news": ["fetch_news"],
|
|
"us2kr.collectors.events": ["fetch_events", "fetch_earnings"],
|
|
"us2kr.store": ["Store"],
|
|
"us2kr.summarize": ["build_provider", "summarize_payload"],
|
|
"us2kr.render": ["render_markdown", "render_telegram_chunks"],
|
|
"us2kr.deliver": ["build_deliverers"],
|
|
}
|
|
|
|
|
|
def _placeholder(name: str):
|
|
def _stub(*_args, **_kwargs):
|
|
raise RuntimeError(f"{name} is a test stub - monkeypatch it in the test")
|
|
|
|
_stub.__name__ = name
|
|
return _stub
|
|
|
|
|
|
def install_missing_stubs() -> list[str]:
|
|
"""Return the list of module names that were stubbed (empty when everything exists)."""
|
|
stubbed: list[str] = []
|
|
for modname, names in _STUBS.items():
|
|
if modname in sys.modules and not isinstance(sys.modules[modname], _StubModule):
|
|
module = sys.modules[modname]
|
|
elif importlib.util.find_spec(modname) is None:
|
|
module = _StubModule(modname)
|
|
sys.modules[modname] = module
|
|
stubbed.append(modname)
|
|
else:
|
|
module = importlib.import_module(modname)
|
|
for name in names:
|
|
if not hasattr(module, name): # package exists but the attribute is not written yet
|
|
setattr(module, name, _placeholder(name))
|
|
return stubbed
|
|
|
|
|
|
class _StubModule(types.ModuleType):
|
|
pass
|
|
|
|
|
|
# Importing this helper (before any ``us2kr.pipeline/daemon/cli`` import) installs the stubs.
|
|
STUBBED_MODULES = install_missing_stubs()
|
|
|
|
|
|
# --------------------------------------------------------------------------- shared fakes
|
|
ET = ZoneInfo("America/New_York")
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
|
|
|
|
class FakeClock:
|
|
"""Deterministic stand-in for us2kr.market_time.MarketClock (weekdays are sessions)."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
run_offset_minutes: int = 75,
|
|
krx_open: bool = True,
|
|
latest: date = date(2026, 8, 25),
|
|
catchup: date | None = None,
|
|
next_run: tuple[date, datetime] | None = None,
|
|
) -> None:
|
|
self.run_offset_minutes = run_offset_minutes
|
|
self.krx_open = krx_open
|
|
self.latest = latest
|
|
self.catchup = catchup
|
|
self.next_run = next_run
|
|
self.calls: list[tuple[str, object]] = []
|
|
|
|
def is_us_session(self, d: date) -> bool:
|
|
return d.weekday() < 5
|
|
|
|
def is_krx_session(self, d: date) -> bool:
|
|
return self.krx_open and d.weekday() < 5
|
|
|
|
def us_session_close(self, session: date) -> datetime:
|
|
if not self.is_us_session(session):
|
|
raise ValueError(f"{session} is not a US session")
|
|
return datetime(session.year, session.month, session.day, 16, 0, tzinfo=ET)
|
|
|
|
def previous_us_session(self, before: date) -> date:
|
|
d = before - timedelta(days=1)
|
|
while not self.is_us_session(d):
|
|
d -= timedelta(days=1)
|
|
return d
|
|
|
|
def next_us_session(self, after: date) -> date:
|
|
d = after + timedelta(days=1)
|
|
while not self.is_us_session(d):
|
|
d += timedelta(days=1)
|
|
return d
|
|
|
|
def next_krx_session(self, on_or_after: date) -> date:
|
|
d = on_or_after
|
|
for _ in range(30):
|
|
if self.is_krx_session(d):
|
|
return d
|
|
d += timedelta(days=1)
|
|
return on_or_after + timedelta(days=30) # krx_open=False: KRX "never" reopens -> briefing superseded
|
|
|
|
def latest_completed_session(self, now: datetime) -> date:
|
|
self.calls.append(("latest_completed_session", now))
|
|
return self.latest
|
|
|
|
def briefing_time_for(self, session: date) -> datetime:
|
|
return (self.us_session_close(session) + timedelta(minutes=self.run_offset_minutes)).astimezone(KST)
|
|
|
|
def krx_date_for(self, session: date) -> date:
|
|
return self.briefing_time_for(session).date()
|
|
|
|
def next_briefing(self, now: datetime) -> tuple[date, datetime]:
|
|
self.calls.append(("next_briefing", now))
|
|
if self.next_run is not None:
|
|
return self.next_run
|
|
d = now.astimezone(ET).date()
|
|
while not self.is_us_session(d) or self.briefing_time_for(d) <= now:
|
|
d += timedelta(days=1)
|
|
return d, self.briefing_time_for(d)
|
|
|
|
def catchup_candidate(self, now: datetime, window_hours: int) -> date | None:
|
|
self.calls.append(("catchup_candidate", (now, window_hours)))
|
|
return self.catchup
|
|
|
|
|
|
class FakeStore:
|
|
"""In-memory stand-in for us2kr.store.Store."""
|
|
|
|
def __init__(self, *, notes: list[str] | None = None, fail_history: bool = False) -> None:
|
|
self.quotes: dict[date, list] = {}
|
|
self.reports: dict[tuple[date, str], int] = {}
|
|
self.saved_results: list = []
|
|
self.sent: list[tuple[int, str, str]] = []
|
|
self.raw: dict[tuple[date, str], str] = {}
|
|
self.notes = notes or []
|
|
self.fail_history = fail_history
|
|
self.closed = False
|
|
self._next_id = 1
|
|
# weekly-recap sources: dict rows mimicking sqlite3.Row's __getitem__ access
|
|
self.quote_rows: list[dict] = []
|
|
self.report_rows: list[dict] = []
|
|
|
|
def save_quotes(self, session_date: date, quotes: list) -> None:
|
|
self.quotes[session_date] = list(quotes)
|
|
|
|
def history_notes(self, session_date: date, quotes: list) -> list[str]:
|
|
if self.fail_history:
|
|
raise RuntimeError("db locked")
|
|
return list(self.notes)
|
|
|
|
def has_report(self, krx_date: date, kind: str = "briefing") -> bool:
|
|
return (krx_date, kind) in self.reports
|
|
|
|
def save_report(self, result, kind: str = "briefing") -> int:
|
|
report_id = self._next_id
|
|
self._next_id += 1
|
|
self.reports[(result.payload.krx_date, kind)] = report_id
|
|
self.saved_results.append(result)
|
|
return report_id
|
|
|
|
def mark_sent(self, report_id: int, channel: str, receipt: str) -> None:
|
|
self.sent.append((report_id, channel, receipt))
|
|
|
|
def save_raw(self, session_date: date, name: str, payload_json: str) -> None:
|
|
self.raw[(session_date, name)] = payload_json
|
|
|
|
def load_quotes_between(self, start: date, end: date) -> list[dict]:
|
|
rows = [r for r in self.quote_rows if start.isoformat() <= r["session_date"] <= end.isoformat()]
|
|
return sorted(rows, key=lambda r: (r["symbol"], r["session_date"]))
|
|
|
|
def reports_between(self, start: date, end: date, kind: str = "briefing") -> list[dict]:
|
|
rows = [r for r in self.report_rows if start.isoformat() <= r["krx_date"] <= end.isoformat()]
|
|
return sorted(rows, key=lambda r: r["krx_date"])
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class FakeDeliverer:
|
|
def __init__(self, name: str = "fake", *, fail: bool = False) -> None:
|
|
self.name = name
|
|
self.fail = fail
|
|
self.delivered: list = []
|
|
self.errors: list[tuple[str, str]] = []
|
|
|
|
def deliver(self, result) -> str:
|
|
if self.fail:
|
|
raise RuntimeError(f"{self.name} is down")
|
|
self.delivered.append(result)
|
|
return f"{self.name}-receipt-{len(self.delivered)}"
|
|
|
|
def notify_error(self, subject: str, detail: str) -> None:
|
|
self.errors.append((subject, detail))
|