Files
jsahnandClaude Fable 5 f5fa7ec70f Add a Monday-morning weekly recap briefing
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>
2026-09-12 23:52:31 +09:00

124 lines
4.8 KiB
Python

"""Shared data models. Every module talks to the others through these types only."""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
class Quote(BaseModel):
symbol: str
name: str
group: str
close: float | None = None
prev_close: float | None = None
change_pct: float | None = None # (close/prev_close - 1) * 100
change_abs: float | None = None # close - prev_close (used for rates: yield points)
day_high: float | None = None
day_low: float | None = None
volume: float | None = None
avg_volume_10d: float | None = None
after_hours_price: float | None = None
after_hours_change_pct: float | None = None # vs regular close
as_of: datetime | None = None
source: str = "yfinance"
class NewsItem(BaseModel):
title: str
summary: str = ""
link: str
source: str
published: datetime
class EconEvent(BaseModel):
name: str
time: datetime | None = None
country: str = "US"
actual: str | None = None
forecast: str | None = None
previous: str | None = None
importance: Literal["high", "medium", "low"] = "medium"
kind: Literal["released", "upcoming"] = "released"
class EarningsItem(BaseModel):
symbol: str
name: str
report_date: date
timing: Literal["bmo", "amc", "unknown"] = "unknown"
eps_actual: float | None = None
eps_estimate: float | None = None
revenue_actual: float | None = None
revenue_estimate: float | None = None
class MarketPayload(BaseModel):
"""Everything collected for one briefing, before LLM summarisation."""
session_date: date # US trading date that just closed
us_close_time: datetime # tz-aware (America/New_York)
generated_at: datetime # tz-aware (Asia/Seoul)
krx_date: date # the Korean trading date this briefing is for
krx_open_today: bool
quotes: list[Quote] = Field(default_factory=list)
news: list[NewsItem] = Field(default_factory=list)
events: list[EconEvent] = Field(default_factory=list)
earnings: list[EarningsItem] = Field(default_factory=list)
history_notes: list[str] = Field(default_factory=list) # e.g. "S&P 500 3일 연속 하락"
data_gaps: list[str] = Field(default_factory=list) # collectors that failed / partial
def quotes_by_group(self) -> dict[str, list[Quote]]:
out: dict[str, list[Quote]] = {}
for q in self.quotes:
out.setdefault(q.group, []).append(q)
return out
class Report(BaseModel):
"""LLM output schema. Used verbatim as the JSON Schema for structured output."""
headline: str = Field(description="한 줄 총평 (40자 이내)")
market_overview: str = Field(description="지수·섹터 흐름 3~5문장")
key_drivers: list[str] = Field(description="상승/하락 요인 3~6개, 각 한 문장")
rates_fx_commodities: str = Field(description="금리·달러·원화·유가·금 흐름 2~4문장")
korea_implications: list[str] = Field(description="한국 장 시사점 3~6개 (반도체/SOX, 환율, ADR, EWY 등)")
watchlist_today: list[str] = Field(description="오늘 한국 장에서 확인할 것 2~5개")
risk_flags: list[str] = Field(description="주의사항 1~4개")
data_gaps: list[str] = Field(default_factory=list, description="수집 실패로 반영하지 못한 항목")
class VerificationIssue(BaseModel):
claim: str = Field(description="문제가 된 브리핑 속 서술 (원문 그대로 인용)")
problem: str = Field(description="제공된 데이터와 어떻게 다른지 한 문장")
severity: Literal["minor", "major"] = Field(
default="minor", description="major: 수치·방향이 데이터와 모순 / minor: 근거 불충분·표현 문제"
)
class Verification(BaseModel):
"""LLM fact-check verdict for a generated Report, judged only against the payload."""
ok: bool = Field(description="major 이슈가 하나도 없으면 true")
issues: list[VerificationIssue] = Field(default_factory=list, description="발견된 문제 목록 (없으면 빈 배열)")
@property
def has_major(self) -> bool:
return any(i.severity == "major" for i in self.issues)
class BriefingResult(BaseModel):
payload: MarketPayload
report: Report | None
markdown: str
model_used: str | None = None
degraded: bool = False # True when LLM failed and only the numeric table was produced
error: str | None = None
verification: Verification | None = None # None: not run (disabled / degraded / verifier failed)
# "briefing": daily post-close briefing. "weekly": Monday-morning recap, where payload.quotes carry
# week-over-week changes, payload.session_date is the week's last US session and krx_date the Monday.
kind: Literal["briefing", "weekly"] = "briefing"