"""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"