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>
243 lines
9.4 KiB
Python
243 lines
9.4 KiB
Python
"""Monday-morning weekly recap: aggregate the stored week into one briefing.
|
|
|
|
No US session closes into Monday KST (the US Sunday never trades), so the Monday slot carries a
|
|
recap of the previous week's US sessions instead. Everything is built from what the daily pipeline
|
|
already stored - the `quotes` table for week-over-week changes and the daily reports for the
|
|
narrative - so no collector runs.
|
|
|
|
The result is a regular ``BriefingResult`` with ``kind="weekly"`` whose payload reuses
|
|
``MarketPayload``: ``quotes`` carry weekly changes (close = Friday close, prev_close = the previous
|
|
week's close), ``history_notes`` carry the daily briefing headlines, ``session_date`` is the week's
|
|
last US session and ``krx_date`` the Monday. That keeps the store, every deliverer and the
|
|
dashboard working unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from us2kr.config import KST, Settings
|
|
from us2kr.llm.base import LLMError
|
|
from us2kr.llm.prompts import WEEKLY_SYSTEM_PROMPT, build_weekly_user_message
|
|
from us2kr.market_time import MarketClock
|
|
from us2kr.models import BriefingResult, MarketPayload, Quote, Report, Verification
|
|
from us2kr.pipeline import SKIP_ALREADY_SENT, _deliver, _skip_result
|
|
from us2kr.render import render_weekly_markdown
|
|
from us2kr.store import Store
|
|
from us2kr.summarize import build_provider, summarize_weekly_payload, verify_and_revise
|
|
from us2kr.symbols import display_name, group_of
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
WEEKDAY_KO = ("월", "화", "수", "목", "금", "토", "일")
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(ZoneInfo(KST))
|
|
|
|
|
|
def most_recent_monday(today: date) -> date:
|
|
"""Today if it is a Monday, otherwise the latest past Monday."""
|
|
return today - timedelta(days=today.weekday())
|
|
|
|
|
|
def week_sessions_for(monday: date, clock: MarketClock) -> list[date]:
|
|
"""The previous week's US sessions (Mon..Fri before `monday`), holidays excluded."""
|
|
return [
|
|
d
|
|
for d in (monday - timedelta(days=offset) for offset in range(7, 2, -1))
|
|
if clock.is_us_session(d)
|
|
]
|
|
|
|
|
|
def _weekly_quote(symbol: str, rows: list) -> Quote:
|
|
"""Collapse one symbol's session rows (oldest first) into a week-over-week Quote."""
|
|
first, last = rows[0], rows[-1]
|
|
base = first["prev_close"] if first["prev_close"] is not None else first["close"]
|
|
end = last["close"]
|
|
change_pct = None
|
|
change_abs = None
|
|
if end is not None and base:
|
|
change_pct = (end / base - 1.0) * 100.0
|
|
change_abs = end - base
|
|
highs = [r["day_high"] for r in rows if r["day_high"] is not None]
|
|
lows = [r["day_low"] for r in rows if r["day_low"] is not None]
|
|
return Quote(
|
|
symbol=symbol,
|
|
name=display_name(symbol),
|
|
group=group_of(symbol) or "etc",
|
|
close=end,
|
|
prev_close=base,
|
|
change_pct=change_pct,
|
|
change_abs=change_abs,
|
|
day_high=max(highs) if highs else None,
|
|
day_low=min(lows) if lows else None,
|
|
source="store",
|
|
)
|
|
|
|
|
|
def _daily_headline_notes(store: Store, monday: date, gaps: list[str]) -> list[str]:
|
|
"""'미 {세션}({요일}): {헤드라인}' lines from the week's stored daily briefings."""
|
|
notes: list[str] = []
|
|
for row in store.reports_between(monday - timedelta(days=6), monday - timedelta(days=1)):
|
|
try:
|
|
data = json.loads(row["result_json"] or "{}")
|
|
except json.JSONDecodeError:
|
|
gaps.append(f"daily report {row['krx_date']}: unreadable")
|
|
continue
|
|
session = row["session_date"] or data.get("payload", {}).get("session_date") or "?"
|
|
try:
|
|
weekday = WEEKDAY_KO[date.fromisoformat(session).weekday()]
|
|
label = f"미 {session}({weekday})"
|
|
except ValueError:
|
|
label = f"미 {session}"
|
|
report = data.get("report") or {}
|
|
headline = (report.get("headline") or "").strip()
|
|
notes.append(f"{label}: {headline}" if headline else f"{label}: (요약 없음)")
|
|
return notes
|
|
|
|
|
|
def build_weekly_payload(
|
|
monday: date, settings: Settings, clock: MarketClock, store: Store
|
|
) -> tuple[MarketPayload, list[date]]:
|
|
"""Assemble the weekly MarketPayload for the Monday `monday` from stored data only."""
|
|
sessions = week_sessions_for(monday, clock)
|
|
gaps: list[str] = []
|
|
|
|
quotes: list[Quote] = []
|
|
covered: set[str] = set()
|
|
if sessions:
|
|
by_symbol: dict[str, list] = {}
|
|
for row in store.load_quotes_between(sessions[0], sessions[-1]):
|
|
by_symbol.setdefault(row["symbol"], []).append(row)
|
|
covered.add(row["session_date"])
|
|
quotes = [_weekly_quote(symbol, rows) for symbol, rows in sorted(by_symbol.items())]
|
|
missing = [s for s in sessions if s.isoformat() not in covered]
|
|
if not quotes:
|
|
gaps.append("weekly quotes: no stored sessions for last week")
|
|
elif missing:
|
|
gaps.append("weekly quotes: missing sessions " + ", ".join(s.isoformat() for s in missing))
|
|
|
|
history_notes = _daily_headline_notes(store, monday, gaps)
|
|
if not history_notes:
|
|
gaps.append("daily headlines: no stored briefings for last week")
|
|
|
|
last_session = sessions[-1] if sessions else monday - timedelta(days=3)
|
|
payload = MarketPayload(
|
|
session_date=last_session,
|
|
us_close_time=clock.us_session_close(last_session),
|
|
generated_at=_now(),
|
|
krx_date=monday,
|
|
krx_open_today=clock.is_krx_session(monday),
|
|
quotes=quotes,
|
|
history_notes=history_notes,
|
|
data_gaps=gaps,
|
|
)
|
|
log.info(
|
|
"weekly payload for %s: %d sessions, %d symbols, %d headlines, %d gaps",
|
|
monday,
|
|
len(sessions),
|
|
len(quotes),
|
|
len(history_notes),
|
|
len(gaps),
|
|
)
|
|
return payload, sessions
|
|
|
|
|
|
def _summarize_weekly(
|
|
payload: MarketPayload, sessions: list[date], settings: Settings
|
|
) -> tuple[Report | None, str | None, str | None]:
|
|
"""Return (report, model_used, error); any failure degrades instead of aborting (pipeline policy)."""
|
|
try:
|
|
provider = build_provider(settings)
|
|
report, model_used = summarize_weekly_payload(payload, sessions, provider)
|
|
return report, model_used, None
|
|
except LLMError as exc:
|
|
log.error("weekly LLM summarisation failed, degrading: %s", exc)
|
|
return None, None, str(exc)
|
|
except Exception as exc:
|
|
log.exception("unexpected weekly LLM failure, degrading")
|
|
return None, None, f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
def _verify_weekly(
|
|
payload: MarketPayload, sessions: list[date], report: Report, settings: Settings
|
|
) -> tuple[Report, Verification | None]:
|
|
try:
|
|
provider = build_provider(settings)
|
|
return verify_and_revise(
|
|
payload,
|
|
report,
|
|
provider,
|
|
data_message=build_weekly_user_message(payload, sessions),
|
|
system_prompt=WEEKLY_SYSTEM_PROMPT,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - verification must never block the recap
|
|
log.warning("weekly verification failed, delivering unverified: %s: %s", type(exc).__name__, exc)
|
|
return report, None
|
|
|
|
|
|
def run_weekly(
|
|
monday: date | None,
|
|
settings: Settings,
|
|
*,
|
|
dry_run: bool = False,
|
|
force: bool = False,
|
|
no_llm: bool = False,
|
|
) -> BriefingResult:
|
|
"""Produce and deliver the weekly recap for ``monday`` (default: the most recent Monday, KST)."""
|
|
clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
|
|
store = Store(settings.db_path)
|
|
try:
|
|
now = _now()
|
|
if monday is None:
|
|
monday = most_recent_monday(now.date())
|
|
if monday.weekday() != 0:
|
|
raise ValueError(f"{monday} is not a Monday - the weekly recap is keyed by Mondays (KST)")
|
|
if monday > now.date():
|
|
raise ValueError(f"{monday} is in the future")
|
|
|
|
payload, sessions = build_weekly_payload(monday, settings, clock, store)
|
|
if not dry_run and not force and store.has_report(monday, kind="weekly"):
|
|
log.info("weekly recap for %s already sent - skipping (use --force to resend)", monday)
|
|
return _skip_result(payload, SKIP_ALREADY_SENT)
|
|
|
|
report: Report | None = None
|
|
model_used: str | None = None
|
|
error: str | None = None
|
|
verification: Verification | None = None
|
|
degraded = False
|
|
if no_llm:
|
|
degraded = True
|
|
log.info("--no-llm: skipping weekly summarisation")
|
|
else:
|
|
report, model_used, error = _summarize_weekly(payload, sessions, settings)
|
|
degraded = report is None
|
|
if report is not None and settings.verify_enabled:
|
|
report, verification = _verify_weekly(payload, sessions, report, settings)
|
|
|
|
markdown = render_weekly_markdown(payload, report, sessions)
|
|
result = BriefingResult(
|
|
payload=payload,
|
|
report=report,
|
|
markdown=markdown,
|
|
model_used=model_used,
|
|
degraded=degraded,
|
|
error=error,
|
|
verification=verification,
|
|
kind="weekly",
|
|
)
|
|
if dry_run:
|
|
print(markdown)
|
|
return result
|
|
|
|
report_id = store.save_report(result, kind="weekly")
|
|
log.info("weekly report %d saved for %s (degraded=%s)", report_id, monday, degraded)
|
|
_deliver(result, settings, store, report_id)
|
|
return result
|
|
finally:
|
|
store.close()
|