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>
124 lines
4.8 KiB
Python
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"
|