From 8bc43b3ce4227272dc74e1c12978bdd71dc592ed Mon Sep 17 00:00:00 2001 From: jsahn Date: Mon, 31 Aug 2026 14:33:53 +0900 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 8196 bytes .env.example | 21 ++ .gitignore | 11 + PLAN.md | 210 +++++++++++++ README.md | 94 ++++++ deploy/Dockerfile | 12 + deploy/com.us2kr.daemon.plist | 40 +++ deploy/compose.yaml | 18 ++ deploy/install-launchd.sh | 16 + deploy/us2kr.service | 26 ++ pyproject.toml | 39 +++ tests/_orchestration_stubs.py | 198 ++++++++++++ tests/test_cli.py | 242 ++++++++++++++ tests/test_config.py | 47 +++ tests/test_daemon.py | 576 ++++++++++++++++++++++++++++++++++ tests/test_deliver.py | 328 +++++++++++++++++++ tests/test_events.py | 261 +++++++++++++++ tests/test_llm.py | 560 +++++++++++++++++++++++++++++++++ tests/test_logging_setup.py | 163 ++++++++++ tests/test_market_time.py | 285 +++++++++++++++++ tests/test_news.py | 168 ++++++++++ tests/test_pipeline.py | 478 ++++++++++++++++++++++++++++ tests/test_prices.py | 463 +++++++++++++++++++++++++++ tests/test_render.py | 365 +++++++++++++++++++++ tests/test_store.py | 355 +++++++++++++++++++++ us2kr/.DS_Store | Bin 0 -> 6148 bytes us2kr/__init__.py | 3 + us2kr/cli.py | 176 +++++++++++ us2kr/collectors/__init__.py | 0 us2kr/collectors/base.py | 29 ++ us2kr/collectors/events.py | 312 ++++++++++++++++++ us2kr/collectors/news.py | 175 +++++++++++ us2kr/collectors/prices.py | 335 ++++++++++++++++++++ us2kr/config.py | 81 +++++ us2kr/daemon.py | 334 ++++++++++++++++++++ us2kr/deliver/__init__.py | 24 ++ us2kr/deliver/base.py | 23 ++ us2kr/deliver/file.py | 52 +++ us2kr/deliver/telegram.py | 161 ++++++++++ us2kr/llm/__init__.py | 0 us2kr/llm/base.py | 17 + us2kr/llm/minimax.py | 305 ++++++++++++++++++ us2kr/llm/prompts.py | 207 ++++++++++++ us2kr/logging_setup.py | 131 ++++++++ us2kr/market_time.py | 181 +++++++++++ us2kr/models.py | 100 ++++++ us2kr/pipeline.py | 286 +++++++++++++++++ us2kr/render.py | 435 +++++++++++++++++++++++++ us2kr/store.py | 418 ++++++++++++++++++++++++ us2kr/summarize.py | 30 ++ us2kr/symbols.py | 111 +++++++ 51 files changed, 8902 insertions(+) create mode 100644 .DS_Store create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 deploy/Dockerfile create mode 100644 deploy/com.us2kr.daemon.plist create mode 100644 deploy/compose.yaml create mode 100755 deploy/install-launchd.sh create mode 100644 deploy/us2kr.service create mode 100644 pyproject.toml create mode 100644 tests/_orchestration_stubs.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_daemon.py create mode 100644 tests/test_deliver.py create mode 100644 tests/test_events.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_logging_setup.py create mode 100644 tests/test_market_time.py create mode 100644 tests/test_news.py create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_prices.py create mode 100644 tests/test_render.py create mode 100644 tests/test_store.py create mode 100644 us2kr/.DS_Store create mode 100644 us2kr/__init__.py create mode 100644 us2kr/cli.py create mode 100644 us2kr/collectors/__init__.py create mode 100644 us2kr/collectors/base.py create mode 100644 us2kr/collectors/events.py create mode 100644 us2kr/collectors/news.py create mode 100644 us2kr/collectors/prices.py create mode 100644 us2kr/config.py create mode 100644 us2kr/daemon.py create mode 100644 us2kr/deliver/__init__.py create mode 100644 us2kr/deliver/base.py create mode 100644 us2kr/deliver/file.py create mode 100644 us2kr/deliver/telegram.py create mode 100644 us2kr/llm/__init__.py create mode 100644 us2kr/llm/base.py create mode 100644 us2kr/llm/minimax.py create mode 100644 us2kr/llm/prompts.py create mode 100644 us2kr/logging_setup.py create mode 100644 us2kr/market_time.py create mode 100644 us2kr/models.py create mode 100644 us2kr/pipeline.py create mode 100644 us2kr/render.py create mode 100644 us2kr/store.py create mode 100644 us2kr/summarize.py create mode 100644 us2kr/symbols.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..555a4e23cd5e3b184695f6395e28015b21fe8f9f GIT binary patch literal 8196 zcmeHMv2GJV5S;}Z8>~nuZXybF9W)@4E)s=tOp^*JA)-Opi9>=obC#V%N_P^1ih?2y z9W;oNBC#R`Uu#(wT&?To#06;>giXq(!!9&XGotwm$e{(@J)E8rFI3U~#)0{?>o*t5Bn zbM}4X^`lq7D{v|m;P*p-&bDT4Y^;8DV9+H1Yyp>b!*|pHIw!EUW^HV&PQ^V<^`LC3 zvL%Kx>F@^}4qLM}HrAw*GU=o&va%J5(&)$wbU3M+v3~Rlcm>84;M%=J*J+JHYRUS& z(-py~h)&dKtcKAlBFfFX->UnUzuX$XPbX@hAga5`rv(n$6!>&$m$qn~`WPR8T?>6P zIa^4g!zNIViIzNHzkPb|!0J3``D_HQtL0jEYS z%yUbkle**hB=*nWM{`F8dzdScv-}d*?mg%Dv5VhXJiH&)OR^7Pit+gPvX~K^e%l!o`2{cVQ2YW+$L*V!YbwGk6^db=p2xa zkQ`P9*S^~MPu)k(M1GXZN23*ozj#-P>6Hceg;(-;_iFFpFj25dWJfsoRXqq4A694A?^WW`S5cv{6JHo*)X?!h$AG`t+QsAt?nc@2XcK-SQ zgxGi(uYgzJUnrmojip8%$hNi~ 2026-08-25 확정: 요약 LLM은 **MiniMax** 사용. `.env`의 `MINIMAX_API_KEY`(Coding/Token Plan 키, `sk-cp-…`)로 +> 글로벌 엔드포인트 `https://api.minimax.io` 에서 M3/M2.7/M2.5 호출 및 JSON Schema 출력·`reasoning_split` 동작을 실제 호출로 검증함. +> (중국 엔드포인트 `api.minimaxi.com` 은 이 키로 401 → 사용 금지) + +- **엔드포인트**: OpenAI 호환 `POST https://api.minimax.io/v1/chat/completions` + - 클라이언트: `openai` SDK에 `base_url="https://api.minimax.io/v1"` 지정 (재시도·타임아웃 내장). 순수 `httpx` 로 대체 가능 + - Anthropic 호환 `…/anthropic/v1/messages` 도 동작하지만, 한 가지로 통일 → OpenAI 호환 채택 +- **모델**: 기본 `MiniMax-M3` (1M 컨텍스트, `thinking` 제어 가능, 프롬프트 캐시 `cached_tokens` 확인됨) + - 장애/오류 시 폴백: `MiniMax-M2.7` → `MiniMax-M2.5` (설정으로 순서 변경) + - M2.x 는 thinking 비활성 불가 → 반드시 `reasoning_split` 사용 +- **필수 요청 파라미터** + - `reasoning_split: true` → 추론이 `message.reasoning_content` 로 분리되고 `content` 에는 답만 남음 + (미설정 시 `content` 안에 `…` 가 섞여 나옴 — 방어적으로 `` 블록 strip 유틸도 둠) + - `response_format: {"type":"json_schema","json_schema":{"name":…,"schema":…}}` → Pydantic 모델의 `model_json_schema()` 를 그대로 사용, 응답은 `model_validate_json` 으로 검증 + - `max_completion_tokens`: 8192 (리포트 JSON + 추론 여유). M3 권장 상한 131072 + - M3 한정 `thinking: {"type":"adaptive"}` (기본) — 요약 품질 위해 켜둠. 비용/속도 필요 시 `disabled` +- **프롬프트 캐시**: 고정 시스템 프롬프트(역할·출력 규칙·용어집·스키마 설명)를 앞에, 당일 데이터는 user 메시지 뒤에 → `prompt_tokens_details.cached_tokens` 로 적중 확인 +- **LLM 계층 추상화**: `us2kr/llm/base.py` 에 `summarize(payload) -> ReportSchema` 인터페이스, `llm/minimax.py` 구현. + 나중에 Claude/OpenAI 로 바꾸려면 구현체만 추가 (프롬프트·스키마는 공용) +- 리포트 스키마(초안): + ``` + headline: str # 한 줄 총평 + market_overview: str # 지수/섹터 흐름 3~5문장 + key_drivers: list[str] # 상승/하락 요인 + rates_fx_commodities: str # 금리·달러·원화·유가 + korea_implications: list[str] # 반도체/SOX, 환율, ADR, EWY → 한국 장 시사점 + watchlist_today: list[str] # 오늘 한국 장에서 볼 것(지표 발표, 실적, 이벤트) + risk_flags: list[str] # 주의사항 + data_gaps: list[str] # 수집 실패 항목(투명성) + ``` +- 프롬프트 원칙: 숫자는 제공 데이터만 사용(환각 금지), 확인 안 된 뉴스는 "보도에 따르면" 표기, + 투자 조언 아닌 정보 요약 톤, 출력 언어 한국어 +- 비용: Coding/Token Plan 구독 키 → 토큰당 과금 없이 플랜 쿼터 소모. 1일 1~2회 호출(입력 ~15K/출력 ~3K 토큰)이라 쿼터 부담 미미. + 레이트리밋·쿼터 초과(429) 시 지수 백오프 재시도 후 폴백 모델 +## 5. 발송 + +- 1차: **Telegram Bot** (`python-telegram-bot` 또는 raw HTTPS) — 설정 가장 단순, 모바일 알림 +- 선택: Slack Incoming Webhook, 이메일(SMTP), 로컬 `reports/YYYY-MM-DD.md` +- Telegram 4096자 제한 → 섹션 단위 분할 전송, 또는 요약본 + 전체본 파일 첨부 +- 실패 알림: 파이프라인 예외·데이터 전부 누락 시 운영자 채널로 에러 메시지 + +## 6. 상주 실행 구조 + +``` +[daemon.py] APScheduler(BackgroundScheduler) + ├─ 매일 00:05 ET: 캘린더 조회 → 오늘 XNYS 세션 있으면 close+75m 에 run_briefing 예약 + ├─ (선택) 08:20 KST: run_premarket_update + ├─ 매시간: heartbeat(로그/헬스파일) + └─ 예외 → 재시도(3회, 지수 백오프) → 실패 알림 +``` + +- **프로세스 감시자는 OS에 맡김**: macOS `launchd`(KeepAlive) / Linux `systemd`(Restart=always) / Docker(`restart: unless-stopped`) +- 재시작 후에도 중복 발송 방지: `reports` 테이블에 당일 기록 있으면 스킵 +- 시각 판단은 항상 "다음 실행 시각 계산" 방식 → 프로세스가 죽었다가 살아나도 놓친 브리핑은 `catch-up` 정책(마감 후 3시간 이내면 즉시 실행, 아니면 스킵) +- 로깅: `logging` + 일자별 회전 파일, 수집 원본 JSON도 날짜별 보관(디버깅용) + +## 7. 프로젝트 구조 + +``` +US2KR/ +├── pyproject.toml # deps: openai(MiniMax OpenAI-호환 클라이언트), yfinance, feedparser, exchange_calendars, +│ # apscheduler, pydantic, pydantic-settings, python-dotenv +├── .env.example # MINIMAX_API_KEY, MINIMAX_BASE_URL(=https://api.minimax.io/v1), MINIMAX_MODEL(=MiniMax-M3), +│ # TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, FRED_API_KEY ... +├── us2kr/ +│ ├── config.py # pydantic-settings, 심볼 목록, 실행 오프셋 등 +│ ├── market_time.py # exchange_calendars 래퍼: 세션/휴장/다음 실행 시각 +│ ├── collectors/ +│ │ ├── base.py # Collector 프로토콜, 타임아웃·재시도·실패 격리 +│ │ ├── prices.py # yfinance (+fallback) +│ │ ├── news.py # RSS +│ │ └── events.py # 경제지표·실적 일정 +│ ├── store.py # SQLite (snapshots, reports) +│ ├── llm/ +│ │ ├── base.py # LLM 인터페이스 + ReportSchema(Pydantic) +│ │ ├── minimax.py # OpenAI-호환 호출, reasoning_split, json_schema, 폴백 +│ │ └── prompts.py # 시스템 프롬프트(캐시 대상)·유저 템플릿 +│ ├── summarize.py # payload 구성 → llm.summarize → 검증 +│ ├── render.py # JSON → Markdown/Telegram 포맷 +│ ├── deliver/ +│ │ ├── telegram.py +│ │ ├── slack.py +│ │ └── file.py +│ ├── pipeline.py # run_briefing(date): collect → store → summarize → render → deliver +│ ├── daemon.py # 스케줄러 상주 루프 +│ └── cli.py # us2kr run-once [--date] [--dry-run] | daemon | backfill | test-notify +├── deploy/ +│ ├── com.us2kr.daemon.plist +│ ├── us2kr.service +│ └── Dockerfile / compose.yaml +├── tests/ # market_time(서머타임·휴장·조기폐장), render, 파이프라인(수집기 mock) +└── reports/ # 생성된 브리핑 아카이브 +``` + +## 8. 구현 단계 + +| 단계 | 내용 | 완료 기준 | +|---|---|---| +| **P1 MVP** | config, prices collector, llm/minimax, summarize, render, `run-once --dry-run` | 터미널에서 오늘자 브리핑 Markdown 출력 | +| **P2 스케줄** | market_time, daemon, store(idempotency), catch-up | 서머타임·휴장·조기폐장 테스트 통과, 상주 실행 | +| **P3 데이터 확장** | news, events collector, 전일 대비 비교, data_gaps | 뉴스·일정 포함 리포트 | +| **P4 발송** | Telegram(+파일 아카이브), 실패 알림, 분할 전송 | 실제 폰으로 수신 | +| **P5 배포** | launchd/systemd/Docker, 로그 회전, README | 재부팅 후 자동 기동, 1주 무인 운영 | +| P6 (선택) | 08:20 개장 직전 업데이트, Slack/이메일, 주간 리뷰 | — | + +## 9. 결정 필요 사항 (기본값으로 진행하되 바꿀 수 있음) + +1. **발송 채널**: 기본 Telegram. Slack/이메일 원하면 P4에서 추가 +2. **실행 환경**: 이 Mac에서 launchd로 상주 vs 별도 서버/Docker. 기본은 Mac launchd + Docker 파일 동봉 +3. **데이터 유료화 여부**: 기본 무료(yfinance/RSS/FRED). 안정성 필요하면 Polygon/Finnhub 유료 티어 +4. **리포트 톤/길이**: 기본 A4 1장 내 한국어. 영어 병기 여부 +5. **08:20 프리마켓 업데이트** 포함 여부 (기본 미포함, 옵션) + +## 10. 리스크와 대응 + +- yfinance 비공식 API → 스키마 변경/차단 가능: 수집기 추상화 + 2차 소스 + 실패 시에도 발행 +- 서머타임 전환 주 오류 → `zoneinfo` + 캘린더 기반 계산, 전환일 단위 테스트 +- LLM 환각(숫자) → 구조화 출력 + "제공 데이터만 사용" 규칙 + 렌더링 시 숫자는 원본 데이터에서 직접 삽입 +- MiniMax `` 누출 / JSON 깨짐 → `reasoning_split` 필수 + strip 유틸 + Pydantic 검증 실패 시 1회 재요청 +- MiniMax 장애·429 → 모델 폴백(M3→M2.7→M2.5) → 그래도 실패면 LLM 없이 '숫자 요약표만' 발송(degraded mode) +- 중복/누락 발송 → SQLite 기록 기반 idempotency + catch-up 정책 +- 키 유출 → `.env` + `.gitignore`, 로그에 시크릿 마스킹 + +--- + +## 11. 구현 현황 (2026-08-25) + +P1~P5 코드 구현 완료. 검증: pytest 287개 통과, ruff 클린, 라이브 dry-run(MiniMax-M3 요약 포함) 정상, 데몬 기동/스케줄/SIGTERM 종료 확인. + +| 영역 | 파일 | 상태 | +|---|---|---| +| 시간/캘린더 | `us2kr/market_time.py` | ✅ XNYS/XKRX, 조기폐장·DST·휴장 테스트, 임시휴장 override(`US2KR_KRX_EXTRA_HOLIDAYS`) | +| 수집 | `collectors/prices.py`, `news.py`, `events.py` | ✅ yfinance 배치 + 애프터마켓(1분봉), RSS 7개, FRED(키 필요)·실적 | +| 요약 | `llm/minimax.py`, `llm/prompts.py`, `summarize.py` | ✅ M3→M2.7→M2.5 폴백, `reasoning_split`, JSON Schema, 수리 재시도 | +| 저장/렌더 | `store.py`, `render.py` | ✅ SQLite(WAL), 히스토리 노트, Markdown + Telegram HTML 청크 | +| 발송 | `deliver/file.py`, `deliver/telegram.py` | ✅ 파일 아카이브 항상, Telegram은 토큰 설정 시 (아직 미설정) | +| 오케스트레이션 | `pipeline.py`, `daemon.py`, `cli.py`, `logging_setup.py` | ✅ 멱등성(저장→발송 순), catch-up, misfire 복구, 시크릿 마스킹 | +| 배포 | `deploy/` | ✅ launchd / systemd / Docker 파일 (미설치) | + +남은 운영 작업: +1. `.env`에 `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` 추가 → `us2kr test-notify`로 확인 +2. (선택) `FRED_API_KEY` 추가 → 경제지표 섹션 활성화 +3. `bash deploy/install-launchd.sh`로 상주 등록 (노트북이면 `pmset` 예약 기동 필요) +4. 첫 실제 브리핑(2026-08-26 06:15 KST) 결과 확인 후 프롬프트/길이 튠 + +알려진 제한: 실적 스캔은 15종목 상한(ADR 일부 미포함), 뉴스는 수집 시점 기준 상한 없음(정규 실행에는 무영향), LLM 산출 `data_gaps`는 렌더에 미표시. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e5d459 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# US2KR — 미국 마감 → 한국 개장 전 브리핑 봇 + +미국 정규장이 끝난 뒤(16:00 ET), 한국 장이 열리기 전(09:00 KST) 하루 한 번 +미국 시장 데이터를 수집하고 MiniMax LLM으로 한국어 브리핑을 만들어 발송하는 상주 프로그램입니다. +설계 문서는 [PLAN.md](PLAN.md)를 참고하세요. + +``` +수집(yfinance · RSS · FRED) → SQLite 저장 → MiniMax-M3 요약(JSON 스키마) → Markdown 렌더 → Telegram / 파일 +``` + +## 실행 시각 + +| | ET | KST | +|---|---|---| +| 미국 정규장 마감 | 16:00 | 05:00 (3~11월 EDT) / 06:00 (11~3월 EST) | +| **브리핑 발송** (마감 +75분, 기본) | 17:15 | **06:15 / 07:15** | +| 한국 개장 | — | 09:00 | + +- 시각은 하드코딩이 아니라 `exchange_calendars`(XNYS/XKRX)로 계산 → 미국 휴장·조기폐장·서머타임 자동 대응 +- 미국 휴장일은 스킵, 한국 휴장일은 리포트에 "오늘 한국 휴장" 표기. `US2KR_SKIP_WHEN_KRX_CLOSED=true`면 + 한국 휴장 연휴 중 **다음 미국 세션이 먼저 마감되어 무의미해지는 브리핑만** 스킵 — 금요일 세션(토요일 도착)은 + 월요일 개장용으로 항상 발송 + +## 설치 + +```bash +# Python 3.12 + uv +uv venv --python 3.12 .venv +uv pip install --python .venv/bin/python -e ".[dev]" + +cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택 +``` + +## 사용 + +```bash +.venv/bin/python -m us2kr.cli show-config # 설정 확인(시크릿 마스킹) +.venv/bin/python -m us2kr.cli next-run # 다음 브리핑 세션/시각 +.venv/bin/python -m us2kr.cli run-once --dry-run # 지금 즉시 생성해서 터미널에 출력(발송 X) +.venv/bin/python -m us2kr.cli run-once --dry-run --no-llm # LLM 없이 숫자표만(축소 모드) +.venv/bin/python -m us2kr.cli run-once --date 2026-08-24 # 특정 미국 세션 기준 생성·발송(마감된 세션만) +.venv/bin/python -m us2kr.cli run-once --force # 이미 발송된 날이어도 다시(재발송) +.venv/bin/python -m us2kr.cli test-notify # 발송 채널 점검 +.venv/bin/python -m us2kr.cli daemon # 상주 실행(스케줄러) +``` + +종료 코드: `0` 성공 · `1` 오류(발송 채널 실패 포함) · `2` 스킵(이미 발송 / 한국 휴장) + +아직 마감되지 않은 세션을 `--date`로 지정하면 `--dry-run`이 아닌 한 거부합니다(장중 데이터가 저장되면 +데몬의 정규 브리핑이 "이미 발송"으로 건너뛰기 때문). 발송 테스트는 `--date <직전 세션> --force`로 하세요. + +## 상주 실행(배포) + +프로세스 생존은 OS에 맡기고, 프로그램은 "다음 실행 시각 계산 → 대기 → 실행"만 반복합니다. +재시작 후 마감 3시간(`US2KR_CATCHUP_WINDOW_HOURS`) 이내면 놓친 브리핑을 즉시 따라잡고, SQLite 기록으로 중복 발송을 막습니다. + +| 환경 | 방법 | +|---|---| +| macOS | `bash deploy/install-launchd.sh` (launchd, KeepAlive) | +| Linux | `deploy/us2kr.service` → `systemctl enable --now us2kr` | +| Docker | `docker compose -f deploy/compose.yaml up -d --build` | + +> macOS 노트북은 **잠자기 중에는 실행되지 않습니다.** 06~07시에 깨어 있도록 +> `sudo pmset repeat wakeorpoweron MTWRFS 06:00:00` 로 예약 기동을 걸거나, 항상 켜진 머신/서버에서 돌리세요. + +## 설정(.env) + +| 변수 | 기본값 | 설명 | +|---|---|---| +| `MINIMAX_API_KEY` | — | 필수. 글로벌 엔드포인트(`api.minimax.io`) 키 | +| `MINIMAX_MODEL` | `MiniMax-M3` | 기본 모델 | +| `MINIMAX_FALLBACK_MODELS` | `MiniMax-M2.7,MiniMax-M2.5` | 실패 시 순차 폴백 | +| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | 둘 다 있으면 Telegram 발송 | +| `FRED_API_KEY` | — | 있으면 경제지표 발표 일정 포함 | +| `US2KR_RUN_OFFSET_MINUTES` | `75` | 미국 마감 후 몇 분 뒤 실행 | +| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 | +| `US2KR_SKIP_WHEN_KRX_CLOSED` | `false` | 한국 휴장일에 다음 미국 세션이 먼저 마감되어 브리핑이 무의미해질 때만 스킵(금요일 세션은 항상 발송) | +| `US2KR_NEWS_LOOKBACK_HOURS` / `US2KR_NEWS_MAX_ITEMS` | `18` / `40` | 뉴스 수집 범위 | +| `US2KR_KRX_EXTRA_HOLIDAYS` | — | 캘린더에 없는 한국 임시휴장일 CSV (예: `2026-06-03`) | + +## 산출물 + +- `reports/YYYY-MM-DD.md`, `.json` — 한국 거래일 기준 아카이브 +- `data/us2kr.sqlite3` — 시세 스냅샷·리포트·발송 기록(전일 대비 비교, 중복 방지) +- `logs/us2kr.log` — 일자별 회전, API 키 마스킹 + +## 개발 + +```bash +.venv/bin/python -m pytest -q +.venv/bin/ruff check us2kr tests +``` + +LLM은 `us2kr/llm/base.py`의 `LLMProvider` 인터페이스 뒤에 있으므로, 다른 모델로 바꾸려면 구현체만 추가하면 됩니다. diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..21fc2cf --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY us2kr ./us2kr +RUN pip install --no-cache-dir . \ + && mkdir -p /app/data /app/reports /app/logs + +VOLUME ["/app/data", "/app/reports", "/app/logs"] +CMD ["python", "-m", "us2kr.cli", "daemon"] diff --git a/deploy/com.us2kr.daemon.plist b/deploy/com.us2kr.daemon.plist new file mode 100644 index 0000000..1171cd5 --- /dev/null +++ b/deploy/com.us2kr.daemon.plist @@ -0,0 +1,40 @@ + + + + + Label + com.us2kr.daemon + + ProgramArguments + + /Users/jsahn/US2KR/.venv/bin/python + -m + us2kr.cli + daemon + + + WorkingDirectory + /Users/jsahn/US2KR + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + PYTHONUNBUFFERED + 1 + + + + KeepAlive + + RunAtLoad + + ThrottleInterval + 30 + + StandardOutPath + /Users/jsahn/US2KR/logs/launchd.out.log + StandardErrorPath + /Users/jsahn/US2KR/logs/launchd.err.log + + diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..3708c23 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,18 @@ +# docker compose -f deploy/compose.yaml up -d --build +services: + us2kr: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: us2kr + env_file: + - ../.env + environment: + US2KR_DATA_DIR: /app/data + US2KR_REPORTS_DIR: /app/reports + US2KR_LOG_DIR: /app/logs + volumes: + - ../data:/app/data + - ../reports:/app/reports + - ../logs:/app/logs + restart: unless-stopped diff --git a/deploy/install-launchd.sh b/deploy/install-launchd.sh new file mode 100755 index 0000000..09442e0 --- /dev/null +++ b/deploy/install-launchd.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# macOS: launchd 사용자 에이전트로 등록/갱신. 실행: bash deploy/install-launchd.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLIST_SRC="$ROOT/deploy/com.us2kr.daemon.plist" +PLIST_DST="$HOME/Library/LaunchAgents/com.us2kr.daemon.plist" +mkdir -p "$ROOT/logs" "$HOME/Library/LaunchAgents" +# 프로젝트 경로가 다르면 plist 안의 경로를 현재 위치로 치환 +sed "s#/Users/jsahn/US2KR#$ROOT#g" "$PLIST_SRC" > "$PLIST_DST" +launchctl bootout "gui/$(id -u)/com.us2kr.daemon" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$PLIST_DST" +launchctl kickstart -k "gui/$(id -u)/com.us2kr.daemon" +echo "installed: $PLIST_DST" +echo "status: launchctl print gui/$(id -u)/com.us2kr.daemon | head -20" +echo "logs: tail -f $ROOT/logs/us2kr.log" +echo "remove: launchctl bootout gui/$(id -u)/com.us2kr.daemon && rm $PLIST_DST" diff --git a/deploy/us2kr.service b/deploy/us2kr.service new file mode 100644 index 0000000..b62bec4 --- /dev/null +++ b/deploy/us2kr.service @@ -0,0 +1,26 @@ +# Linux systemd unit. 설치: +# sudo cp deploy/us2kr.service /etc/systemd/system/us2kr.service +# sudo systemctl daemon-reload && sudo systemctl enable --now us2kr +# journalctl -u us2kr -f +[Unit] +Description=US2KR - US market close -> Korea market open briefing daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=us2kr +WorkingDirectory=/opt/us2kr +EnvironmentFile=/opt/us2kr/.env +Environment=PYTHONUNBUFFERED=1 +ExecStart=/opt/us2kr/.venv/bin/python -m us2kr.cli daemon +Restart=always +RestartSec=30 +# 하드닝 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/us2kr/data /opt/us2kr/reports /opt/us2kr/logs + +[Install] +WantedBy=multi-user.target diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f1db74e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "us2kr" +version = "0.1.0" +description = "US market close -> Korea market open briefing bot" +requires-python = ">=3.12,<3.13" +dependencies = [ + "openai>=1.50", + "yfinance>=0.2.50", + "pandas>=2.2", + "feedparser>=6.0", + "exchange-calendars>=4.5", + "apscheduler>=3.10,<4", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "python-dotenv>=1.0", + "httpx>=0.27", + "requests>=2.31", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-mock>=3.14", "respx>=0.21", "ruff>=0.6", "freezegun>=1.5"] + +[project.scripts] +us2kr = "us2kr.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["us2kr"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 110 +target-version = "py312" diff --git a/tests/_orchestration_stubs.py b/tests/_orchestration_stubs.py new file mode 100644 index 0000000..2baa2a2 --- /dev/null +++ b/tests/_orchestration_stubs.py @@ -0,0 +1,198 @@ +"""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 + + 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 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)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..21b31e8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,242 @@ +"""CLI tests: settings/clock/pipeline monkeypatched on us2kr.cli.""" + +from __future__ import annotations + +from datetime import date, datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest +from _orchestration_stubs import FakeClock, FakeDeliverer + +from us2kr import cli +from us2kr.config import Settings +from us2kr.models import BriefingResult, MarketPayload + +KST = ZoneInfo("Asia/Seoul") +ET = ZoneInfo("America/New_York") +SECRET = "sk-cp-verysecretkeyvalue1234567890" +BOT_TOKEN = "123456789:AAH-abcdefghijklmnopqrstuvwxyz0123" + + +def make_settings(tmp_path, **overrides) -> Settings: + values = { + "US2KR_DATA_DIR": str(tmp_path / "data"), + "US2KR_REPORTS_DIR": str(tmp_path / "reports"), + "US2KR_LOG_DIR": str(tmp_path / "logs"), + "MINIMAX_API_KEY": SECRET, + "TELEGRAM_BOT_TOKEN": BOT_TOKEN, + "TELEGRAM_CHAT_ID": "-1001", + } + values.update(overrides) + return Settings(_env_file=None, **values) + + +def make_result(session=date(2026, 8, 25), error=None, degraded=False) -> BriefingResult: + payload = MarketPayload( + session_date=session, + us_close_time=datetime(2026, 8, 25, 16, 0, tzinfo=ET), + generated_at=datetime(2026, 8, 26, 6, 15, tzinfo=KST), + krx_date=date(2026, 8, 26), + krx_open_today=True, + ) + return BriefingResult(payload=payload, report=None, markdown="# md", degraded=degraded, error=error) + + +@pytest.fixture +def settings(monkeypatch, tmp_path): + s = make_settings(tmp_path) + monkeypatch.setattr(cli, "_load_settings", lambda: s) + monkeypatch.setattr(cli, "setup_logging", lambda settings: None) + return s + + +@pytest.fixture +def fake_clock(monkeypatch): + clock = FakeClock() + monkeypatch.setattr(cli, "MarketClock", lambda offset, **_kw: clock) + return clock + + +# ---------------------------------------------------------------------------------- next-run +def test_next_run_prints_session_kst_time_et_close_and_krx_status(settings, fake_clock, capsys): + fake_clock.next_run = (date(2026, 8, 25), datetime(2026, 8, 26, 6, 15, tzinfo=KST)) + assert cli.main(["next-run"]) == 0 + out = capsys.readouterr().out + assert "2026-08-25" in out + assert "2026-08-26T06:15+09:00" in out + assert "2026-08-25T16:00-04:00" in out and "America/New_York" in out + assert "2026-08-26 (open)" in out + + +def test_next_run_shows_krx_closed(settings, fake_clock, capsys): + fake_clock.krx_open = False + assert cli.main(["next-run"]) == 0 + assert "CLOSED" in capsys.readouterr().out + + +# ------------------------------------------------------------------------------- show-config +def test_show_config_masks_secrets(settings, capsys): + assert cli.main(["show-config"]) == 0 + out = capsys.readouterr().out + assert SECRET not in out and BOT_TOKEN not in out + assert "minimax_api_key = sk-c***" in out + assert "telegram_bot_token = 1234***" in out + assert "telegram_chat_id = -1001" in out # not a secret + assert "minimax_max_completion_tokens = 16384" in out # 'tokens' is not a secret field + assert "fred_api_key = (unset)" in out + assert "run_offset_minutes = 75" in out + assert "telegram_enabled = True" in out + assert "db_path = " in out and "us2kr.sqlite3" in out + + +def test_show_config_unset_secret(monkeypatch, tmp_path, capsys): + s = make_settings(tmp_path, MINIMAX_API_KEY="", TELEGRAM_BOT_TOKEN=None) + monkeypatch.setattr(cli, "_load_settings", lambda: s) + monkeypatch.setattr(cli, "setup_logging", lambda settings: None) + cli.main(["show-config"]) + out = capsys.readouterr().out + assert "minimax_api_key = (unset)" in out + + +# ---------------------------------------------------------------------------------- run-once +class RunBriefingRecorder: + def __init__(self, result=None, exc=None): + self.result = result or make_result() + self.exc = exc + self.calls: list[tuple] = [] + + def __call__(self, session, settings, *, dry_run=False, force=False, no_llm=False): + self.calls.append((session, settings, dry_run, force, no_llm)) + if self.exc: + raise self.exc + return self.result + + +def test_run_once_dry_run_calls_pipeline_with_dry_run(settings, monkeypatch): + rec = RunBriefingRecorder() + monkeypatch.setattr(cli, "run_briefing", rec) + assert cli.main(["run-once", "--dry-run"]) == 0 + assert rec.calls == [(None, settings, True, False, False)] + + +def test_run_once_passes_date_force_no_llm(settings, monkeypatch): + rec = RunBriefingRecorder() + monkeypatch.setattr(cli, "run_briefing", rec) + assert cli.main(["run-once", "--date", "2026-08-25", "--force", "--no-llm"]) == 0 + assert rec.calls == [(date(2026, 8, 25), settings, False, True, True)] + + +def test_run_once_invalid_date_is_usage_error(settings): + with pytest.raises(SystemExit) as exc: + cli.main(["run-once", "--date", "2026/08/25"]) + assert exc.value.code == 2 + + +@pytest.mark.parametrize("reason", ["already sent", "krx closed"]) +def test_run_once_skip_reasons_exit_2(settings, monkeypatch, capsys, reason): + monkeypatch.setattr(cli, "run_briefing", RunBriefingRecorder(result=make_result(error=reason))) + assert cli.main(["run-once"]) == 2 + assert reason in capsys.readouterr().err + + +def test_run_once_degraded_still_exit_0(settings, monkeypatch): + rec = RunBriefingRecorder(result=make_result(error="LLM failed", degraded=True)) + monkeypatch.setattr(cli, "run_briefing", rec) + assert cli.main(["run-once"]) == 0 + + +def test_run_once_delivery_failure_exit_1(settings, monkeypatch, capsys): + err = "delivery failed: telegram: DeliveryError: 400 chat not found" + monkeypatch.setattr(cli, "run_briefing", RunBriefingRecorder(result=make_result(error=err))) + assert cli.main(["run-once"]) == 1 + assert "telegram" in capsys.readouterr().err + + +def test_run_once_exception_exit_1(settings, monkeypatch, capsys): + monkeypatch.setattr(cli, "run_briefing", RunBriefingRecorder(exc=ValueError("not a US trading session"))) + assert cli.main(["run-once", "--date", "2026-08-23"]) == 1 + assert "not a US trading session" in capsys.readouterr().err + + +# ------------------------------------------------------------------------------------ daemon +def test_daemon_subcommand_calls_run_daemon(settings, monkeypatch): + called = [] + monkeypatch.setattr(cli, "run_daemon", lambda s: called.append(s)) + assert cli.main(["daemon"]) == 0 + assert called == [settings] + + +# ------------------------------------------------------------------------------- test-notify +def test_test_notify_sends_through_every_deliverer(settings, monkeypatch, capsys): + a, b = FakeDeliverer("file"), FakeDeliverer("telegram") + monkeypatch.setattr(cli, "build_deliverers", lambda s: [a, b]) + assert cli.main(["test-notify", "--message", "hello from test"]) == 0 + assert a.errors == [("US2KR 테스트 알림", "hello from test")] + assert b.errors == a.errors + out = capsys.readouterr().out + assert "file" in out and "telegram" in out + + +def test_test_notify_without_channels_is_error(settings, monkeypatch): + monkeypatch.setattr(cli, "build_deliverers", lambda s: []) + assert cli.main(["test-notify"]) == 1 + + +# ---------------------------------------------------------------------------- settings/paths +def test_resolve_paths_makes_relative_paths_absolute(tmp_path): + s = Settings( + _env_file=None, US2KR_DATA_DIR="data", US2KR_REPORTS_DIR="/abs/reports", US2KR_LOG_DIR="logs" + ) + resolved = cli._resolve_paths(s, root=tmp_path) + assert resolved.data_dir == tmp_path / "data" + assert resolved.log_dir == tmp_path / "logs" + assert resolved.reports_dir == Path("/abs/reports") + assert resolved.db_path == tmp_path / "data" / "us2kr.sqlite3" + assert s.data_dir == Path("data") # original untouched + + +def test_load_settings_falls_back_to_project_root_env(monkeypatch, tmp_path): + root = tmp_path / "proj" + root.mkdir() + (root / ".env").write_text("US2KR_RUN_OFFSET_MINUTES=90\nUS2KR_DATA_DIR=var/data\n", encoding="utf-8") + cwd = tmp_path / "elsewhere" + cwd.mkdir() + monkeypatch.chdir(cwd) # like launchd starting with cwd=/ + monkeypatch.delenv("US2KR_RUN_OFFSET_MINUTES", raising=False) + monkeypatch.delenv("US2KR_DATA_DIR", raising=False) + monkeypatch.setattr(cli, "PROJECT_ROOT", root) + + s = cli._load_settings() + + assert s.run_offset_minutes == 90 + assert s.data_dir == root / "var" / "data" + assert s.log_dir == root / "logs" + + +def test_load_settings_prefers_cwd_env(monkeypatch, tmp_path): + root = tmp_path / "proj" + root.mkdir() + (root / ".env").write_text("US2KR_RUN_OFFSET_MINUTES=90\n", encoding="utf-8") + cwd = tmp_path / "cwd" + cwd.mkdir() + (cwd / ".env").write_text("US2KR_RUN_OFFSET_MINUTES=45\n", encoding="utf-8") + monkeypatch.chdir(cwd) + monkeypatch.delenv("US2KR_RUN_OFFSET_MINUTES", raising=False) + monkeypatch.setattr(cli, "PROJECT_ROOT", root) + assert cli._load_settings().run_offset_minutes == 45 + + +def test_main_reports_bad_settings(monkeypatch, capsys): + def boom(): + raise ValueError("bad env") + + monkeypatch.setattr(cli, "_load_settings", boom) + assert cli.main(["show-config"]) == 1 + assert "bad env" in capsys.readouterr().err + + +def test_mask_secret(): + assert cli.mask_secret("") == "(unset)" + assert cli.mask_secret("short") == "***" + assert cli.mask_secret("sk-cp-abcdefghijklmnop") == "sk-c***" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..dc8be41 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,47 @@ +"""Settings parsing: CSV env values, aliases, defaults.""" + +from __future__ import annotations + +from datetime import date + +from us2kr.config import Settings + + +def test_csv_env_values_are_split(monkeypatch): + monkeypatch.setenv("MINIMAX_FALLBACK_MODELS", "MiniMax-M2.7, MiniMax-M2.5") + monkeypatch.setenv("US2KR_KRX_EXTRA_HOLIDAYS", "2026-06-03,2026-12-31") + s = Settings(_env_file=None) + assert s.minimax_fallback_models == ["MiniMax-M2.7", "MiniMax-M2.5"] + assert s.krx_extra_holidays == [date(2026, 6, 3), date(2026, 12, 31)] + + +def test_empty_csv_gives_empty_list(monkeypatch): + monkeypatch.setenv("US2KR_KRX_EXTRA_HOLIDAYS", "") + monkeypatch.setenv("MINIMAX_FALLBACK_MODELS", "") + s = Settings(_env_file=None) + assert s.krx_extra_holidays == [] + assert s.minimax_fallback_models == [] + + +def test_defaults_without_env(monkeypatch): + for k in ("MINIMAX_FALLBACK_MODELS", "US2KR_KRX_EXTRA_HOLIDAYS", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"): + monkeypatch.delenv(k, raising=False) + s = Settings(_env_file=None) + assert s.minimax_model == "MiniMax-M3" + assert s.minimax_fallback_models == ["MiniMax-M2.7", "MiniMax-M2.5"] + assert s.krx_extra_holidays == [] + assert s.telegram_enabled is False + + +def test_init_kwargs_accept_field_name_and_alias(monkeypatch): + monkeypatch.delenv("US2KR_RUN_OFFSET_MINUTES", raising=False) + assert Settings(_env_file=None, run_offset_minutes=30).run_offset_minutes == 30 + assert Settings(_env_file=None, US2KR_RUN_OFFSET_MINUTES=45).run_offset_minutes == 45 + + +def test_telegram_enabled_requires_both(monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123456:abcdefghijklmnopqrstuvwxyz") + monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) + assert Settings(_env_file=None).telegram_enabled is False + monkeypatch.setenv("TELEGRAM_CHAT_ID", "42") + assert Settings(_env_file=None).telegram_enabled is True diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..3575ef5 --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,576 @@ +"""Daemon tests with a fake scheduler (captures add_job) and a fake clock.""" + +from __future__ import annotations + +import signal +import threading +import time +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest +from _orchestration_stubs import FakeClock, FakeDeliverer, FakeStore +from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent +from apscheduler.schedulers import SchedulerNotRunningError +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger + +from us2kr import daemon as daemon_mod +from us2kr.config import Settings +from us2kr.daemon import ( + BRIEFING_JOB_ID, + HEARTBEAT_JOB_ID, + PREMARKET_JOB_ID, + RECOVERY_JOB_ID, + Daemon, + _parse_hhmm, +) + +KST = ZoneInfo("Asia/Seoul") +NOW = datetime(2026, 8, 25, 12, 0, tzinfo=KST) # Tuesday noon KST -> next briefing Wed 06:15 KST + + +class FakeScheduler: + def __init__(self): + self.jobs: dict[str, dict] = {} + self.add_calls: list[dict] = [] + self.shutdown_calls: list[bool] = [] + self.listeners: list[tuple[object, int | None]] = [] + + def add_listener(self, callback, mask=None): + self.listeners.append((callback, mask)) + + def add_job(self, func, trigger=None, **kwargs): + entry = {"func": func, "trigger": trigger, **kwargs} + self.add_calls.append(entry) + self.jobs[kwargs.get("id", func.__name__)] = entry + return entry + + def shutdown(self, wait=True): + self.shutdown_calls.append(wait) + + +def make_settings(tmp_path, **overrides) -> Settings: + values = {"US2KR_DATA_DIR": str(tmp_path / "data"), "US2KR_LOG_DIR": str(tmp_path / "logs")} + values.update(overrides) + return Settings(_env_file=None, **values) + + +class RunRecorder: + def __init__(self, fail: bool = False): + self.sessions: list[date] = [] + self.fail = fail + + def __call__(self, session: date): + self.sessions.append(session) + if self.fail: + raise RuntimeError("pipeline exploded") + + +def make_daemon(tmp_path, *, clock=None, store=None, run_fn=None, settings=None, deliverer=None): + scheduler = FakeScheduler() + clock = clock or FakeClock() + store = store or FakeStore() + run_fn = run_fn or RunRecorder() + deliverer = deliverer or FakeDeliverer("file") + d = Daemon( + settings or make_settings(tmp_path), + scheduler=scheduler, + clock=clock, + store=store, + run_fn=run_fn, + now_fn=lambda: NOW, + deliverers_fn=lambda s: [deliverer], + ) + return d, scheduler, clock, store, run_fn, deliverer + + +# -------------------------------------------------------------------------------- scheduling +def test_schedule_next_schedules_one_shot_job_at_next_briefing_time(tmp_path): + d, scheduler, *_ = make_daemon(tmp_path) + expected_run_at = datetime(2026, 8, 26, 6, 15, tzinfo=KST) # Tue 16:00 EDT + 75 min + + session, run_at = d.schedule_next() + + assert session == date(2026, 8, 25) and run_at == expected_run_at + assert d.next_run == (session, run_at) + job = scheduler.jobs[BRIEFING_JOB_ID] + assert isinstance(job["trigger"], DateTrigger) + assert job["trigger"].run_date == expected_run_at + assert job["args"] == [session, run_at] + assert job["replace_existing"] is True + # None: a late (sleep/wake) job must still FIRE so it can reschedule itself; see BRIEFING_MISFIRE_GRACE + assert job["misfire_grace_time"] is None + + +def test_schedule_next_uses_clock_next_briefing_verbatim(tmp_path): + run_at = datetime(2026, 11, 28, 4, 15, tzinfo=KST) # early close day example + clock = FakeClock(next_run=(date(2026, 11, 27), run_at)) + d, scheduler, *_ = make_daemon(tmp_path, clock=clock) + d.schedule_next() + assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == run_at + assert clock.calls[-1] == ("next_briefing", NOW) + + +def test_schedule_next_survives_calendar_error(tmp_path): + class BrokenClock(FakeClock): + def next_briefing(self, now): + raise RuntimeError("calendar unavailable") + + d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenClock()) + assert d.schedule_next() is None + assert BRIEFING_JOB_ID not in scheduler.jobs + # heartbeat retries scheduling + d.clock = FakeClock() + d._heartbeat_job() + assert BRIEFING_JOB_ID in scheduler.jobs + + +def test_start_schedules_heartbeat_and_no_premarket_by_default(tmp_path): + d, scheduler, *_ = make_daemon(tmp_path) + d.start() + assert set(scheduler.jobs) == {BRIEFING_JOB_ID, HEARTBEAT_JOB_ID} + hb = scheduler.jobs[HEARTBEAT_JOB_ID] + assert isinstance(hb["trigger"], IntervalTrigger) + assert hb["trigger"].interval.total_seconds() == 3600 + d._heartbeat_job() # must not raise + + +def test_premarket_job_scheduled_when_enabled(tmp_path, caplog): + settings = make_settings( + tmp_path, US2KR_PREMARKET_UPDATE_ENABLED="true", US2KR_PREMARKET_TIME_KST="08:20" + ) + d, scheduler, *_ = make_daemon(tmp_path, settings=settings) + d.start() + job = scheduler.jobs[PREMARKET_JOB_ID] + trig = job["trigger"] + assert isinstance(trig, CronTrigger) + fields = {f.name: str(f) for f in trig.fields} + assert fields["hour"] == "8" and fields["minute"] == "20" and fields["day_of_week"] == "mon-fri" + assert str(trig.timezone) == "Asia/Seoul" + with caplog.at_level("INFO", logger="us2kr.daemon"): + job["func"]() + assert "premarket update not yet implemented" in caplog.text + + +def test_premarket_extension_point_is_called_when_provided(tmp_path): + calls = [] + d, *_ = make_daemon(tmp_path) + d.premarket_fn = lambda: calls.append("ran") + d._premarket_job() + assert calls == ["ran"] + + +@pytest.mark.parametrize("bad", ["8:", "25:00", "08:60", "abc", "0820"]) +def test_parse_hhmm_rejects_invalid(bad): + with pytest.raises(ValueError): + _parse_hhmm(bad) + + +def test_parse_hhmm_ok(): + assert _parse_hhmm("08:20") == (8, 20) + assert _parse_hhmm(" 7:05 ") == (7, 5) + + +# ---------------------------------------------------------------------------------- catch-up +def test_catchup_runs_when_candidate_and_not_yet_reported(tmp_path): + clock = FakeClock(catchup=date(2026, 8, 24)) + d, _scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path, clock=clock) + + assert d.run_catchup() is True + assert run_fn.sessions == [date(2026, 8, 24)] + assert clock.calls[0] == ("catchup_candidate", (NOW, 3)) + + +def test_catchup_skipped_when_report_exists(tmp_path): + clock = FakeClock(catchup=date(2026, 8, 24)) + store = FakeStore() + store.reports[(date(2026, 8, 25), "briefing")] = 1 # krx_date_for(2026-08-24) == 2026-08-25 + d, _scheduler, clock, store, run_fn, _ = make_daemon(tmp_path, clock=clock, store=store) + + assert d.run_catchup() is False + assert run_fn.sessions == [] + + +def test_catchup_skipped_without_candidate(tmp_path): + d, _scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path) + assert d.run_catchup() is False + assert run_fn.sessions == [] + + +def test_catchup_failure_is_logged_and_start_continues(tmp_path): + clock = FakeClock(catchup=date(2026, 8, 24)) + d, scheduler, clock, _store, run_fn, deliverer = make_daemon( + tmp_path, clock=clock, run_fn=RunRecorder(fail=True) + ) + d.start() # must not raise + assert run_fn.sessions == [date(2026, 8, 24)] + assert BRIEFING_JOB_ID in scheduler.jobs + assert deliverer.errors and "catch-up" in deliverer.errors[0][0] + + +def test_start_uses_catchup_window_from_settings(tmp_path): + settings = make_settings(tmp_path, US2KR_CATCHUP_WINDOW_HOURS="5") + d, _scheduler, clock, *_ = make_daemon(tmp_path, settings=settings) + d.start() + assert clock.calls[0] == ("catchup_candidate", (NOW, 5)) + + +# -------------------------------------------------------------------------------------- job +def test_job_runs_pipeline_and_reschedules(tmp_path): + d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path) + d.schedule_next() + job = scheduler.jobs[BRIEFING_JOB_ID] + session = job["args"][0] + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + job["func"](*job["args"]) + + assert run_fn.sessions == [session] + assert len(scheduler.add_calls) == 2 + assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]] + assert d.next_run == clock.next_run + assert deliverer.errors == [] + + +def test_job_exception_notifies_and_still_reschedules(tmp_path): + d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path, run_fn=RunRecorder(fail=True)) + d.schedule_next() + job = scheduler.jobs[BRIEFING_JOB_ID] + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + job["func"](*job["args"]) # must not raise + + assert run_fn.sessions == [date(2026, 8, 25)] + assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] + assert len(deliverer.errors) == 1 + subject, detail = deliverer.errors[0] + assert "브리핑 실패" in subject and "pipeline exploded" in detail + + +def test_notify_error_failures_are_swallowed(tmp_path): + class Exploding(FakeDeliverer): + def notify_error(self, subject, detail): + raise RuntimeError("telegram down") + + d, *_ = make_daemon(tmp_path, run_fn=RunRecorder(fail=True), deliverer=Exploding("tg")) + d._briefing_job(date(2026, 8, 25)) # must not raise + + d.deliverers_fn = lambda s: (_ for _ in ()).throw(RuntimeError("cannot build")) + d._briefing_job(date(2026, 8, 25)) # must not raise either + + +# --------------------------------------------------------------------------------- shutdown +def test_signal_handler_shuts_scheduler_down(tmp_path): + d, scheduler, *_ = make_daemon(tmp_path) + old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) + try: + d.install_signal_handlers() + handler = signal.getsignal(signal.SIGTERM) + assert callable(handler) and handler is not old_term + handler(signal.SIGTERM, None) + finally: + signal.signal(signal.SIGTERM, old_term) + signal.signal(signal.SIGINT, old_int) + assert scheduler.shutdown_calls == [False] + + +def test_shutdown_is_idempotent(tmp_path): + class Once(FakeScheduler): + def shutdown(self, wait=True): + super().shutdown(wait) + if len(self.shutdown_calls) > 1: + raise RuntimeError("not running") + + d, *_ = make_daemon(tmp_path) + d.scheduler = Once() + d.shutdown() + d.shutdown() # second call raises inside scheduler but is swallowed + + +# ------------------------------------------------------------------------------- run_daemon +def test_run_daemon_wires_real_parts_and_blocks_on_scheduler(monkeypatch, tmp_path): + """run_daemon builds Daemon with a BlockingScheduler; we replace start() so the test does not block.""" + started = {} + fake_clock = FakeClock() + fake_store = FakeStore() + + class FakeBlocking(FakeScheduler): + def __init__(self, timezone=None): + super().__init__() + started["timezone"] = timezone + + def start(self, paused=False): + started["started"] = True + raise KeyboardInterrupt # simulate Ctrl-C ending the blocking loop + + monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking) + monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: fake_clock) + monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store) + monkeypatch.setattr(daemon_mod.signal, "signal", lambda *a, **k: None) + + daemon_mod.run_daemon(make_settings(tmp_path)) + + assert started["started"] is True + assert str(started["timezone"]) == "Asia/Seoul" + assert fake_store.closed is True + + +# ------------------------------------------------------------------- missed / late briefing job +def test_start_registers_missed_job_listener(tmp_path): + d, scheduler, *_ = make_daemon(tmp_path) + d.start() + assert (d._on_job_missed, EVENT_JOB_MISSED) in scheduler.listeners + + +@pytest.mark.parametrize(("lateness_h", "expect_run"), [(0, True), (2, True), (3, False), (5, False)]) +def test_late_briefing_job_runs_inside_window_and_skips_outside(tmp_path, lateness_h, expect_run): + """With misfire grace None the job fires late after sleep/wake; it must apply the catch-up window + (default 3h) and reschedule in every case.""" + d, scheduler, clock, _store, run_fn, deliverer = make_daemon(tmp_path) + run_at = NOW - timedelta(hours=lateness_h) + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + d._briefing_job(date(2026, 8, 25), run_at) + + assert (run_fn.sessions == [date(2026, 8, 25)]) is expect_run + assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # always rescheduled + assert d.next_run == clock.next_run + assert deliverer.errors == [] + + +def test_heartbeat_recovers_when_briefing_job_was_dropped(tmp_path): + """Scheduler lost the one-shot job: heartbeat must catch up (inside window) and re-arm the schedule.""" + now = {"t": NOW} + d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) + d.now_fn = lambda: now["t"] + session, run_at = d.schedule_next() + del scheduler.jobs[BRIEFING_JOB_ID] # what APScheduler does when it drops a misfired DateTrigger job + now["t"] = run_at + timedelta(hours=1, minutes=45) + clock.catchup = session # still inside the 3h catch-up window + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + d._heartbeat_job() + + assert run_fn.sessions == [session] + job = scheduler.jobs[BRIEFING_JOB_ID] + assert job["trigger"].run_date == clock.next_run[1] and job["trigger"].run_date > now["t"] + assert d.next_run == clock.next_run + + +def test_heartbeat_reschedules_stale_next_run_without_running_outside_window(tmp_path): + now = {"t": NOW} + d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) + d.now_fn = lambda: now["t"] + _session, run_at = d.schedule_next() + del scheduler.jobs[BRIEFING_JOB_ID] + now["t"] = run_at + timedelta(hours=4) # clock.catchup stays None: outside the window + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + d._heartbeat_job() + + assert run_fn.sessions == [] + assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] + assert d.next_run == clock.next_run + + +def test_heartbeat_leaves_schedule_alone_when_briefing_is_due_or_barely_late(tmp_path): + """A run time in the last few minutes means the job is running or about to: do not touch it.""" + now = {"t": NOW} + d, scheduler, _clock, _store, run_fn, _ = make_daemon(tmp_path) + d.now_fn = lambda: now["t"] + _session, run_at = d.schedule_next() + for delta in (timedelta(minutes=-30), timedelta(0), timedelta(minutes=2)): + now["t"] = run_at + delta + d._heartbeat_job() + assert len(scheduler.add_calls) == 1 and run_fn.sessions == [] + + +def test_heartbeat_does_not_run_catchup_while_briefing_in_progress(tmp_path): + now = {"t": NOW} + d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) + d.now_fn = lambda: now["t"] + session, run_at = d.schedule_next() + now["t"] = run_at + timedelta(hours=1) + clock.catchup = session + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + with d._run_lock: # a briefing is running on another executor thread + d._heartbeat_job() + assert run_fn.sessions == [] # no concurrent second run + assert scheduler.jobs[BRIEFING_JOB_ID]["trigger"].run_date == clock.next_run[1] # but re-armed + + +def test_on_job_missed_queues_recovery_under_a_different_id(tmp_path): + """Re-adding 'briefing' from the listener would race APScheduler's own remove_job of the exhausted + one-shot job; recovery must go through a separately named job.""" + d, scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path) + d.start() + session, run_at = d.next_run + del scheduler.jobs[BRIEFING_JOB_ID] + clock.catchup = session + clock.next_run = (date(2026, 8, 26), datetime(2026, 8, 27, 6, 15, tzinfo=KST)) + + d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, BRIEFING_JOB_ID, "default", run_at)) + + assert scheduler.add_calls[-1]["id"] == RECOVERY_JOB_ID + assert BRIEFING_JOB_ID not in scheduler.jobs and run_fn.sessions == [] + recovery = scheduler.jobs[RECOVERY_JOB_ID] + assert isinstance(recovery["trigger"], DateTrigger) and recovery["misfire_grace_time"] is None + recovery["func"]() + assert run_fn.sessions == [session] + assert scheduler.jobs[BRIEFING_JOB_ID]["args"] == [date(2026, 8, 26), clock.next_run[1]] + + n_calls = len(scheduler.add_calls) + d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, HEARTBEAT_JOB_ID, "default", run_at)) # ignored + assert len(scheduler.add_calls) == n_calls + + +def test_recover_schedule_survives_catchup_error(tmp_path): + class BrokenCatchup(FakeClock): + def catchup_candidate(self, now, window_hours): + raise RuntimeError("calendar unavailable") + + d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenCatchup()) + d._recover_schedule() # must not raise + assert BRIEFING_JOB_ID in scheduler.jobs + + +class LateThenNormalClock(FakeClock): + """next_briefing answers a queue of (session, run_at) pairs; the last answer repeats.""" + + def __init__(self, answers): + super().__init__() + self.answers = list(answers) + + def next_briefing(self, now): + self.calls.append(("next_briefing", now)) + if len(self.answers) > 1: + return self.answers.pop(0) + return self.answers[0] + + +@pytest.mark.parametrize(("lateness_h", "expect_run"), [(1, True), (4, False)]) +def test_real_scheduler_fires_late_one_shot_job_and_reschedules(tmp_path, lateness_h, expect_run): + """Regression for the misfire bug: a DateTrigger already in the past (machine slept) must still fire + through the real APScheduler, apply the window, and leave a fresh 'briefing' job behind.""" + real_now = datetime.now(KST) + late_session, late_run_at = date(2026, 8, 24), real_now - timedelta(hours=lateness_h) + next_session, next_run_at = date(2026, 8, 25), real_now + timedelta(hours=20) + clock = LateThenNormalClock([(late_session, late_run_at), (next_session, next_run_at)]) + run_fn = RunRecorder() + scheduler = BackgroundScheduler(timezone=KST) + d = Daemon( + make_settings(tmp_path), + scheduler=scheduler, + clock=clock, + store=FakeStore(), + run_fn=run_fn, + deliverers_fn=lambda s: [FakeDeliverer("file")], + ) + d.start() + assert d.next_run == (late_session, late_run_at) + scheduler.start() + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and d.next_run != (next_session, next_run_at): + time.sleep(0.05) + assert d.next_run == (next_session, next_run_at) + job = scheduler.get_job(BRIEFING_JOB_ID) + assert job is not None and job.next_run_time == next_run_at + assert (run_fn.sessions == [late_session]) is expect_run + finally: + scheduler.shutdown(wait=True) + + +# --------------------------------------------------------------- shutdown during start-up catch-up +def test_signal_during_catchup_unwinds_when_scheduler_not_running(tmp_path): + class NotStarted(FakeScheduler): + def shutdown(self, wait=True): + super().shutdown(wait) + raise SchedulerNotRunningError # real APScheduler behaviour before start() + + d, *_ = make_daemon(tmp_path) + d.scheduler = NotStarted() + old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) + try: + d.install_signal_handlers() + handler = signal.getsignal(signal.SIGTERM) + with pytest.raises(KeyboardInterrupt): + handler(signal.SIGTERM, None) + finally: + signal.signal(signal.SIGTERM, old_term) + signal.signal(signal.SIGINT, old_int) + assert d.stop_requested is True + assert d.scheduler.shutdown_calls == [False] + + +def test_shutdown_during_catchup_prevents_scheduling(tmp_path): + class NotStarted(FakeScheduler): + def shutdown(self, wait=True): + super().shutdown(wait) + raise SchedulerNotRunningError + + holder: dict = {} + + def run_fn(session): # simulates the signal handler firing mid catch-up (swallowed variant) + holder["d"].shutdown() + + clock = FakeClock(catchup=date(2026, 8, 24)) + d, *_ = make_daemon(tmp_path, clock=clock, run_fn=run_fn) + d.scheduler = NotStarted() + holder["d"] = d + d.start() + assert d.stop_requested is True + assert d.scheduler.jobs == {} # nothing scheduled; run_daemon skips scheduler.start() + + +def test_run_daemon_stops_cleanly_when_signal_arrives_during_catchup(monkeypatch, tmp_path): + handlers: dict = {} + started: dict = {} + fake_store = FakeStore() + + class FakeBlocking(FakeScheduler): + def __init__(self, timezone=None): + super().__init__() + self.running = False + + def start(self, paused=False): + started["started"] = True + + def shutdown(self, wait=True): + super().shutdown(wait) + if not self.running: + raise SchedulerNotRunningError + + def run_briefing(session, settings): + handlers[signal.SIGTERM](signal.SIGTERM, None) # SIGTERM while the catch-up briefing runs + raise AssertionError("unreachable: the handler must unwind the main thread") + + monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking) + monkeypatch.setattr(daemon_mod, "MarketClock", lambda offset, **_kw: FakeClock(catchup=date(2026, 8, 24))) + monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store) + monkeypatch.setattr(daemon_mod, "run_briefing", run_briefing) + monkeypatch.setattr( + daemon_mod.signal, "signal", lambda signum, handler: handlers.__setitem__(signum, handler) + ) + + daemon_mod.run_daemon(make_settings(tmp_path)) # must return, not hang or raise + + assert "started" not in started + assert fake_store.closed is True + + +def test_run_lock_serialises_catchup_and_job(tmp_path): + """Sanity: the lock used by heartbeat recovery is the one held while run_fn executes.""" + seen = {} + d, *_ = make_daemon(tmp_path) + + def run_fn(session): + seen["locked"] = d._run_lock.locked() + + d.run_fn = run_fn + d._briefing_job(date(2026, 8, 25), NOW) + assert seen["locked"] is True + assert isinstance(d._run_lock, type(threading.Lock())) diff --git a/tests/test_deliver.py b/tests/test_deliver.py new file mode 100644 index 0000000..7dcda13 --- /dev/null +++ b/tests/test_deliver.py @@ -0,0 +1,328 @@ +"""Tests for us2kr.deliver (file archive, Telegram, factory). No network.""" + +from __future__ import annotations + +import json +import logging +import sys +import time +import types +from collections.abc import Callable +from datetime import date, datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +import httpx +import pytest + +from us2kr.config import Settings +from us2kr.deliver import build_deliverers +from us2kr.deliver.base import DeliveryError +from us2kr.deliver.file import FileDeliverer +from us2kr.deliver.telegram import TelegramDeliverer +from us2kr.models import BriefingResult, MarketPayload, Quote, Report + +TOKEN = "123456789:AAHsecretTOKENvalueXYZ" +CHAT = "-1001234567890" + + +# ------------------------------------------------------------------ fixtures +@pytest.fixture +def result() -> BriefingResult: + payload = MarketPayload( + session_date=date(2026, 8, 24), + us_close_time=datetime(2026, 8, 24, 16, 0, tzinfo=ZoneInfo("America/New_York")), + generated_at=datetime(2026, 8, 25, 6, 15, tzinfo=ZoneInfo("Asia/Seoul")), + krx_date=date(2026, 8, 25), + krx_open_today=True, + quotes=[Quote(symbol="^GSPC", name="S&P 500", group="index", close=5500.0, change_pct=0.5)], + data_gaps=["news: timeout"], + ) + report = Report( + headline="미 증시 소폭 상승", + market_overview="S&P 500이 0.5% 올랐다.", + key_drivers=["기술주 강세"], + rates_fx_commodities="10년물 금리 보합.", + korea_implications=["반도체 긍정적"], + watchlist_today=["삼성전자"], + risk_flags=["환율 변동성"], + data_gaps=["news: timeout"], + ) + return BriefingResult( + payload=payload, + report=report, + markdown="# 브리핑 2026-08-25\n\n## 시장\nS&P 500 +0.5%\n", + model_used="MiniMax-M3", + ) + + +@pytest.fixture +def fake_render(monkeypatch: pytest.MonkeyPatch) -> Callable[[list[str]], None]: + """Install a stub us2kr.render module returning fixed chunks (render may not exist yet).""" + + def install(chunks: list[str]) -> None: + mod = types.ModuleType("us2kr.render") + mod.render_telegram_chunks = lambda markdown, limit=4000: list(chunks) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "us2kr.render", mod) + + return install + + +@pytest.fixture +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: + calls: list[float] = [] + monkeypatch.setattr(time, "sleep", lambda s: calls.append(s)) + return calls + + +def _ok(message_id: int) -> httpx.Response: + return httpx.Response(200, json={"ok": True, "result": {"message_id": message_id}}) + + +def _err(status: int, description: str, **params) -> httpx.Response: + body = {"ok": False, "error_code": status, "description": description} + if params: + body["parameters"] = params + return httpx.Response(status, json=body) + + +class Scripted: + """MockTransport handler that records requests and replays scripted responses.""" + + def __init__(self, responses: list[httpx.Response | Exception]) -> None: + self.responses = list(responses) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if not self.responses: + raise AssertionError("unexpected extra request") + item = self.responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + def bodies(self) -> list[dict]: + return [json.loads(r.content) for r in self.requests] + + +def make_deliverer(responses: list[httpx.Response | Exception]) -> tuple[TelegramDeliverer, Scripted]: + handler = Scripted(responses) + client = httpx.Client(transport=httpx.MockTransport(handler)) + return TelegramDeliverer(TOKEN, CHAT, client=client), handler + + +# ------------------------------------------------------------ FileDeliverer +def test_file_deliverer_writes_md_and_json(tmp_path: Path, result: BriefingResult) -> None: + reports_dir = tmp_path / "nested" / "reports" # does not exist yet + d = FileDeliverer(reports_dir) + assert d.name == "file" + + receipt = d.deliver(result) + + md_path = reports_dir / "2026-08-25.md" + assert receipt == str(md_path) + assert md_path.read_text(encoding="utf-8") == result.markdown + json_path = reports_dir / "2026-08-25.json" + restored = BriefingResult.model_validate_json(json_path.read_text(encoding="utf-8")) + assert restored == result + assert "\n " in json_path.read_text(encoding="utf-8") # indent=2 + + +def test_file_deliverer_overwrites_on_redeliver(tmp_path: Path, result: BriefingResult) -> None: + d = FileDeliverer(tmp_path) + d.deliver(result) + updated = result.model_copy(update={"markdown": "# v2\n"}) + d.deliver(updated) + assert (tmp_path / "2026-08-25.md").read_text(encoding="utf-8") == "# v2\n" + + +def test_file_deliverer_notify_error_appends(tmp_path: Path) -> None: + d = FileDeliverer(tmp_path / "r") + d.notify_error("LLM 실패", "LLMError: all models failed\nsecond line") + d.notify_error("수집 실패", "yfinance down") + lines = (tmp_path / "r" / "errors.log").read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert "LLM 실패" in lines[0] and "all models failed | second line" in lines[0] + assert lines[0].startswith("20") # ISO timestamp + assert "yfinance down" in lines[1] + + +def test_file_deliverer_notify_error_never_raises(tmp_path: Path) -> None: + blocker = tmp_path / "file_not_dir" + blocker.write_text("x") + d = FileDeliverer(blocker / "sub") # mkdir will fail: parent is a regular file + d.notify_error("s", "d") # must not raise + + +def test_file_deliverer_deliver_raises_delivery_error(tmp_path: Path, result: BriefingResult) -> None: + blocker = tmp_path / "file_not_dir" + blocker.write_text("x") + with pytest.raises(DeliveryError): + FileDeliverer(blocker / "sub").deliver(result) + + +# -------------------------------------------------------- TelegramDeliverer +def test_telegram_sends_each_chunk_and_returns_first_id(result: BriefingResult, fake_render) -> None: + fake_render(["chunk 1", "chunk 2", "chunk 3"]) + d, handler = make_deliverer([_ok(101), _ok(102), _ok(103)]) + assert d.name == "telegram" + + receipt = d.deliver(result) + + assert receipt == "101" + assert len(handler.requests) == 3 + for req in handler.requests: + assert req.method == "POST" + assert str(req.url) == f"https://api.telegram.org/bot{TOKEN}/sendMessage" + bodies = handler.bodies() + assert [b["text"] for b in bodies] == ["chunk 1", "chunk 2", "chunk 3"] + for body in bodies: + assert body["chat_id"] == CHAT + assert body["parse_mode"] == "HTML" + assert body["disable_web_page_preview"] is True + + +def test_telegram_no_chunks_raises(result: BriefingResult, fake_render) -> None: + fake_render([]) + d, handler = make_deliverer([]) + with pytest.raises(DeliveryError): + d.deliver(result) + assert handler.requests == [] + + +def test_telegram_retries_on_429_honouring_retry_after(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(["only"]) + d, handler = make_deliverer([_err(429, "Too Many Requests: retry after 7", retry_after=7), _ok(5)]) + + assert d.deliver(result) == "5" + + assert len(handler.requests) == 2 + assert len(no_sleep) == 1 and no_sleep[0] >= 7 + + +def test_telegram_retries_5xx_then_succeeds(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(["only"]) + d, handler = make_deliverer([_err(502, "Bad Gateway"), _err(503, "Service Unavailable"), _ok(9)]) + + assert d.deliver(result) == "9" + + assert len(handler.requests) == 3 + assert len(no_sleep) == 2 + assert no_sleep[1] > no_sleep[0] # exponential backoff + + +def test_telegram_gives_up_after_three_5xx(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(["only"]) + d, handler = make_deliverer([_err(500, "boom"), _err(500, "boom"), _err(500, "boom")]) + with pytest.raises(DeliveryError, match="3 attempts"): + d.deliver(result) + assert len(handler.requests) == 3 + + +def test_telegram_transport_error_is_retried(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(["only"]) + d, handler = make_deliverer([httpx.ConnectError("connection refused"), _ok(1)]) + assert d.deliver(result) == "1" + assert len(handler.requests) == 2 + + +def test_telegram_other_4xx_not_retried(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(["only"]) + d, handler = make_deliverer([_err(403, "Forbidden: bot was blocked by the user")]) + with pytest.raises(DeliveryError, match="403"): + d.deliver(result) + assert len(handler.requests) == 1 + assert no_sleep == [] + + +def test_telegram_html_parse_error_falls_back_to_plain(result: BriefingResult, fake_render, no_sleep) -> None: + fake_render(['제목 5 < 6 링크', "second"]) + d, handler = make_deliverer( + [ + _err(400, 'Bad Request: can\'t parse entities: Unsupported start tag "x" at byte offset 3'), + _ok(21), + _ok(22), + ] + ) + + assert d.deliver(result) == "21" + + bodies = handler.bodies() + assert len(bodies) == 3 + assert bodies[0]["parse_mode"] == "HTML" + assert "parse_mode" not in bodies[1] + assert bodies[1]["text"] == "제목 5 < 6 링크" + assert bodies[2]["parse_mode"] == "HTML" and bodies[2]["text"] == "second" + assert no_sleep == [] + + +def test_telegram_notify_error_sends_plain_text() -> None: + d, handler = make_deliverer([_ok(77)]) + d.notify_error("LLM 실패", "x" * 2000) + body = handler.bodies()[0] + assert "parse_mode" not in body + assert body["text"].startswith("⚠️ US2KR 오류: LLM 실패\n") + assert body["text"] == "⚠️ US2KR 오류: LLM 실패\n" + "x" * 1500 + assert body["chat_id"] == CHAT + + +def test_telegram_notify_error_never_raises(no_sleep, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG) + d, _ = make_deliverer([httpx.ConnectError(f"boom https://api.telegram.org/bot{TOKEN}/sendMessage")] * 3) + d.notify_error("subject", "detail") # must swallow + d2, _ = make_deliverer([_err(401, "Unauthorized")]) + d2.notify_error("subject", "detail") # must swallow + assert TOKEN not in caplog.text + assert "notify_error failed" in caplog.text + + +def test_telegram_token_never_logged( + result: BriefingResult, fake_render, no_sleep, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.DEBUG) + fake_render(["a", "b"]) + d, _ = make_deliverer([_err(429, "slow down", retry_after=1), _ok(1), _err(500, "x"), _ok(2)]) + d.deliver(result) + d3, _ = make_deliverer([_err(500, "x")] * 3) + with pytest.raises(DeliveryError) as excinfo: + d3.deliver(result) + assert TOKEN not in str(excinfo.value) + assert TOKEN not in caplog.text + assert caplog.records, "expected some log output" + # httpx's own request log (which contains the URL) must be suppressed + assert not [r for r in caplog.records if r.name == "httpx" and r.levelno < logging.WARNING] + + +def test_telegram_requires_token_and_chat() -> None: + with pytest.raises(ValueError): + TelegramDeliverer("", CHAT) + with pytest.raises(ValueError): + TelegramDeliverer(TOKEN, "") + + +# ---------------------------------------------------------- build_deliverers +def _settings(tmp_path: Path, **overrides) -> Settings: + return Settings(_env_file=None, US2KR_REPORTS_DIR=str(tmp_path / "reports"), **overrides) + + +def test_build_deliverers_file_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False) + monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) + ds = build_deliverers(_settings(tmp_path)) + assert [d.name for d in ds] == ["file"] + assert isinstance(ds[0], FileDeliverer) + assert ds[0].reports_dir == tmp_path / "reports" + + +def test_build_deliverers_with_telegram(tmp_path: Path) -> None: + ds = build_deliverers(_settings(tmp_path, TELEGRAM_BOT_TOKEN=TOKEN, TELEGRAM_CHAT_ID=CHAT)) + assert [d.name for d in ds] == ["file", "telegram"] + assert isinstance(ds[1], TelegramDeliverer) + assert ds[1].chat_id == CHAT + + +def test_build_deliverers_token_without_chat_is_file_only(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) + ds = build_deliverers(_settings(tmp_path, TELEGRAM_BOT_TOKEN=TOKEN)) + assert [d.name for d in ds] == ["file"] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..f7e7dc7 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,261 @@ +"""Tests for us2kr.collectors.events (FRED + yfinance mocked, no network).""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import ClassVar +from zoneinfo import ZoneInfo + +import pandas as pd +import pytest +import requests + +from us2kr.collectors import events +from us2kr.collectors.events import fetch_earnings, fetch_events, next_us_business_day + +ET = ZoneInfo("America/New_York") +SESSION = date(2026, 8, 25) # Tuesday +NEXT = date(2026, 8, 26) + + +# --------------------------------------------------------------------------- +# fetch_events +# --------------------------------------------------------------------------- +class _JsonResp: + def __init__(self, payload: dict, status: int = 200): + self._payload = payload + self.status_code = status + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code}") + + def json(self) -> dict: + return self._payload + + +FRED_PAYLOAD = { + "realtime_start": "2026-08-25", + "realtime_end": "2026-08-26", + "release_dates": [ + {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-08-25"}, + { + "release_id": 180, + "release_name": "Unemployment Insurance Weekly Claims Report", + "date": "2026-08-26", + }, + {"release_id": 101, "release_name": "FOMC Press Release", "date": "2026-08-26"}, + {"release_id": 18, "release_name": "H.15 Selected Interest Rates", "date": "2026-08-25"}, # unmapped + {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-08-25"}, # dup + {"release_id": 53, "release_name": "Gross Domestic Product", "date": "2026-08-24"}, # out of range + {"release_id": 99, "release_name": "Producer Price Index", "date": "garbage"}, + ], +} + + +def test_fetch_events_without_key_returns_empty(mocker): + get = mocker.patch.object(events.requests, "get") + assert fetch_events(SESSION, None) == [] + assert fetch_events(SESSION, "") == [] + get.assert_not_called() + + +def test_fetch_events_maps_and_classifies(mocker): + get = mocker.patch.object(events.requests, "get", return_value=_JsonResp(FRED_PAYLOAD)) + out = fetch_events(SESSION, "test-key") + + assert [e.name for e in out] == ["미국 소비자물가지수(CPI)", "주간 신규 실업수당 청구건수", "FOMC"] + cpi, claims, fomc = out + assert cpi.kind == "released" and cpi.importance == "high" + assert cpi.time == datetime(2026, 8, 25, 8, 30, tzinfo=ET) + assert claims.kind == "upcoming" and claims.importance == "medium" + assert fomc.kind == "upcoming" and fomc.time == datetime(2026, 8, 26, 14, 0, tzinfo=ET) + assert all(e.time is None or e.time.tzinfo is not None for e in out) + assert all(e.country == "US" for e in out) + + params = get.call_args.kwargs["params"] + assert params["realtime_start"] == "2026-08-25" + assert params["realtime_end"] == "2026-08-26" + assert params["include_release_dates_with_no_data"] == "true" + assert params["api_key"] == "test-key" + assert params["file_type"] == "json" + assert get.call_args.args[0] == events.FRED_RELEASE_DATES_URL + assert get.call_args.kwargs["timeout"] == events.FRED_TIMEOUT_SECONDS + + +def test_fetch_events_http_error_propagates(mocker): + mocker.patch.object(events.requests, "get", return_value=_JsonResp({}, status=500)) + with pytest.raises(requests.HTTPError): + fetch_events(SESSION, "k") + + +def test_fetch_events_errors_never_expose_the_api_key(mocker): + """requests puts the full URL (api_key=...) in its error messages; fetch_events must scrub it.""" + key = "0123456789abcdef0123456789abcdef" + url = f"https://api.stlouisfed.org/fred/releases/dates?realtime_start=2026-08-25&api_key={key}&file_type=json" + + class _Resp(_JsonResp): + def raise_for_status(self): + raise requests.HTTPError(f"400 Client Error: Bad Request for url: {url}", response=self) + + mocker.patch.object(events.requests, "get", return_value=_Resp({}, status=400)) + with pytest.raises(requests.HTTPError) as info: + fetch_events(SESSION, key) + assert key not in str(info.value) and "url" not in str(info.value) + assert "HTTP 400" in str(info.value) and info.value.response is not None + assert info.value.__cause__ is None and info.value.__suppress_context__ # original message not chained + + mocker.patch.object( + events.requests, + "get", + side_effect=requests.ConnectionError(f"Max retries exceeded with url: {url} (Caused by ...)"), + ) + with pytest.raises(requests.ConnectionError) as info: + fetch_events(SESSION, key) + assert key not in str(info.value) + assert isinstance(info.value, requests.RequestException) # run_safely still records a data gap + + +def test_fetch_events_empty_payload(mocker): + mocker.patch.object(events.requests, "get", return_value=_JsonResp({"release_dates": []})) + assert fetch_events(SESSION, "k") == [] + + +# --------------------------------------------------------------------------- +# fetch_earnings +# --------------------------------------------------------------------------- +def _earnings_df(rows: list[tuple[datetime, float | None, float | None]]) -> pd.DataFrame: + idx = pd.DatetimeIndex([pd.Timestamp(ts) for ts, _, _ in rows], name="Earnings Date") + return pd.DataFrame( + { + "EPS Estimate": [est for _, est, _ in rows], + "Reported EPS": [act for _, _, act in rows], + "Surprise(%)": [None] * len(rows), + }, + index=idx, + ) + + +class _FakeTicker: + registry: ClassVar[dict[str, _FakeTicker]] = {} + + def __init__(self, ticker: str): + self.ticker = ticker + spec = self.registry.get(ticker, {}) + if spec.get("raise_calendar"): + raise RuntimeError("yahoo down") + self._calendar = spec.get("calendar", {}) + self._df = spec.get("df") + self._raise_df = spec.get("raise_df", False) + + @property + def calendar(self) -> dict: + return self._calendar + + def get_earnings_dates(self, limit: int = 12, offset: int = 0): + if self._raise_df: + raise RuntimeError("scrape failed") + return self._df + + +@pytest.fixture +def fake_yf(monkeypatch): + _FakeTicker.registry = {} + monkeypatch.setattr(events.yf, "Ticker", _FakeTicker) + monkeypatch.setattr(events, "next_us_business_day", lambda d: NEXT) + return _FakeTicker.registry + + +def test_fetch_earnings_matches_session_and_next_day(fake_yf): + fake_yf["NVDA"] = { + "calendar": {"Earnings Date": [SESSION], "Earnings Average": 1.23, "Revenue Average": 45_000_000_000}, + "df": _earnings_df([(datetime(2026, 8, 25, 16, 0, tzinfo=ET), 1.25, 1.31)]), + } + fake_yf["MU"] = { + "calendar": {"Earnings Date": [NEXT, date(2026, 8, 27)]}, + "df": _earnings_df([(datetime(2026, 8, 26, 6, 0, tzinfo=ET), 2.0, float("nan"))]), + } + fake_yf["AAPL"] = {"calendar": {"Earnings Date": [date(2026, 10, 29)]}} + fake_yf["TSLA"] = {"calendar": {}} + + tickers = ["^GSPC", "ES=F", "KRW=X", "BTC-USD", "DX-Y.NYB", "NVDA", "MU", "AAPL", "TSLA"] + out = fetch_earnings(SESSION, tickers) + + assert [e.symbol for e in out] == ["NVDA", "MU"] + nvda, mu = out + assert nvda.report_date == SESSION and nvda.timing == "amc" + assert nvda.name == "엔비디아" + assert nvda.eps_estimate == 1.25 and nvda.eps_actual == 1.31 + assert nvda.revenue_estimate == 45_000_000_000.0 + assert mu.report_date == NEXT and mu.timing == "bmo" + assert mu.eps_estimate == 2.0 and mu.eps_actual is None + assert mu.revenue_estimate is None + + +def test_earnings_dates_wins_over_local_tz_calendar_date(fake_yf): + # yfinance's calendar date is computed in the machine's local zone: on a KST host an amc report + # on SESSION appears as SESSION+1. The tz-aware earnings_dates row must take precedence. + fake_yf["NVDA"] = { + "calendar": {"Earnings Date": [NEXT], "Revenue Average": 92_000_000_000}, + "df": _earnings_df([(datetime(2026, 8, 25, 16, 0, tzinfo=ET), 2.09, None)]), + } + (item,) = fetch_earnings(SESSION, ["NVDA"]) + assert item.report_date == SESSION and item.timing == "amc" + assert item.eps_estimate == 2.09 and item.revenue_estimate == 92_000_000_000.0 + + +def test_calendar_fallback_when_earnings_dates_empty(fake_yf): + fake_yf["MU"] = { + "calendar": {"Earnings Date": [NEXT], "Earnings Average": 2.5, "Revenue Average": 9_000_000_000}, + "df": pd.DataFrame(columns=["EPS Estimate", "Reported EPS", "Surprise(%)"]), + } + (item,) = fetch_earnings(SESSION, ["MU"]) + assert item.report_date == NEXT and item.timing == "unknown" + assert item.eps_estimate == 2.5 and item.revenue_estimate == 9_000_000_000.0 + + +def test_no_match_when_earnings_dates_row_is_elsewhere(fake_yf): + # A confirmed row on another date must not fall through to a (possibly stale) calendar entry. + fake_yf["AAPL"] = { + "calendar": {"Earnings Date": [SESSION]}, + "df": _earnings_df([(datetime(2026, 10, 29, 16, 0, tzinfo=ET), 1.0, None)]), + } + assert fetch_earnings(SESSION, ["AAPL"]) == [] + + +def test_fetch_earnings_per_ticker_isolation_and_unknown_timing(fake_yf): + fake_yf["AMD"] = {"raise_calendar": True} + fake_yf["AVGO"] = {"calendar": {"Earnings Date": [SESSION]}, "raise_df": True} + fake_yf["TSM"] = {"calendar": {"Earnings Date": [SESSION]}, "df": None} + out = fetch_earnings(SESSION, ["AMD", "AVGO", "TSM"]) + assert [(e.symbol, e.timing) for e in out] == [("AVGO", "unknown"), ("TSM", "unknown")] + + +def test_fetch_earnings_caps_ticker_count(fake_yf, mocker): + tickers = [f"T{i}" for i in range(40)] + for t in tickers: + fake_yf[t] = {"calendar": {"Earnings Date": [SESSION]}, "df": None} + spy = mocker.spy(_FakeTicker, "__init__") + out = fetch_earnings(SESSION, tickers) + assert spy.call_count == events.EARNINGS_MAX_TICKERS + assert len(out) == events.EARNINGS_MAX_TICKERS + + +def test_fetch_earnings_respects_time_budget(fake_yf, monkeypatch): + tickers = ["A", "B", "C", "D"] + for t in tickers: + fake_yf[t] = {"calendar": {"Earnings Date": [SESSION]}, "df": None} + clock = iter([0.0, 0.0, 5.0, 100.0, 100.0, 100.0, 100.0, 100.0]) + monkeypatch.setattr(events.time, "monotonic", lambda: next(clock)) + out = fetch_earnings(SESSION, tickers) + assert [e.symbol for e in out] == ["A", "B"] + + +def test_fetch_earnings_empty_tickers(fake_yf): + assert fetch_earnings(SESSION, []) == [] + + +def test_next_us_business_day_skips_weekend_and_holiday(): + assert next_us_business_day(date(2026, 8, 28)) == date(2026, 8, 31) # Fri -> Mon + assert next_us_business_day(date(2026, 7, 2)) == date(2026, 7, 6) # Jul 3 2026 observed holiday + assert next_us_business_day(SESSION) == NEXT diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..2a1711b --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,560 @@ +"""Tests for us2kr.llm.prompts, us2kr.llm.minimax and us2kr.summarize (no network).""" + +from __future__ import annotations + +import json +import re +from datetime import date, datetime +from typing import Any +from zoneinfo import ZoneInfo + +import httpx2 +import openai +import pytest +from openai.types.chat import ChatCompletion +from pydantic import ValidationError + +from us2kr.config import Settings +from us2kr.llm.base import LLMError +from us2kr.llm.minimax import ( + MiniMaxProvider, + coerce_report_data, + extract_json_text, + parse_report, + report_json_schema, + report_response_format, +) +from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message +from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report +from us2kr.summarize import build_provider, summarize_payload + +ET = ZoneInfo("America/New_York") +KST = ZoneInfo("Asia/Seoul") + +GOOD_REPORT: dict[str, Any] = { + "headline": "반도체 강세에 나스닥 상승 마감", + "market_overview": "주요 지수가 상승했습니다. 반도체가 강했습니다.", + "key_drivers": ["엔비디아 실적 기대", "금리 하락"], + "rates_fx_commodities": "10년물 금리가 하락했고 달러/원은 소폭 내렸습니다.", + "korea_implications": ["SOX 상승은 국내 반도체에 긍정적입니다.", "EWY가 올랐습니다."], + "watchlist_today": ["삼성전자 흐름"], + "risk_flags": ["VIX 반등 가능성"], + "data_gaps": ["news: TimeoutError"], +} + + +# --------------------------------------------------------------------------- fixtures +def make_settings(**overrides: Any) -> Settings: + kwargs: dict[str, Any] = { + "MINIMAX_API_KEY": "test-key-not-real", + "MINIMAX_MODEL": "MiniMax-M3", + "MINIMAX_FALLBACK_MODELS": ["MiniMax-M2.7", "MiniMax-M2.5"], + "MINIMAX_MAX_COMPLETION_TOKENS": 4096, + } + kwargs.update(overrides) + return Settings(_env_file=None, **kwargs) + + +def make_payload() -> MarketPayload: + session = date(2026, 3, 10) + return MarketPayload( + session_date=session, + us_close_time=datetime(2026, 3, 10, 16, 0, tzinfo=ET), + generated_at=datetime(2026, 3, 11, 6, 15, tzinfo=KST), + krx_date=date(2026, 3, 11), + krx_open_today=True, + quotes=[ + Quote(symbol="^GSPC", name="S&P 500", group="indices", close=5912.345, change_pct=0.8123), + Quote( + symbol="^SOX", name="필라델피아 반도체(SOX)", group="indices", close=5321.1, change_pct=2.5 + ), + Quote(symbol="^TNX", name="미 10년물", group="rates", close=4.2534, change_abs=-0.0512), + Quote(symbol="KRW=X", name="달러/원", group="fx", close=1385.25, change_pct=-0.31), + Quote( + symbol="NVDA", + name="엔비디아", + group="semis", + close=912.5, + change_pct=3.2, + after_hours_price=920.0, + after_hours_change_pct=0.82, + ), + Quote(symbol="EWY", name="MSCI Korea ETF(EWY)", group="korea_linked", close=None), + ], + news=[ + NewsItem( + title="Nvidia rallies ahead of earnings", + summary="Shares rose as investors bet on strong data-center demand. " * 5, + link="https://example.com/1", + source="CNBC", + published=datetime(2026, 3, 10, 15, 30, tzinfo=ET), + ), + NewsItem( + title="Treasury yields slip", + link="https://example.com/2", + source="MarketWatch", + published=datetime(2026, 3, 10, 14, 0, tzinfo=ET), + ), + ], + events=[ + EconEvent( + name="CPI (YoY)", + time=datetime(2026, 3, 10, 8, 30, tzinfo=ET), + actual="3.1%", + forecast="3.2%", + previous="3.3%", + importance="high", + kind="released", + ), + EconEvent(name="FOMC 의사록", time=None, importance="medium", kind="upcoming"), + ], + earnings=[ + EarningsItem( + symbol="MU", + name="마이크론", + report_date=session, + timing="amc", + eps_actual=1.23, + eps_estimate=1.10, + revenue_actual=8_100_000_000, + revenue_estimate=7_900_000_000, + ) + ], + history_notes=["S&P 500 3일 연속 상승"], + data_gaps=["events: HTTPError"], + ) + + +def make_completion( + content: str | None, + *, + model: str = "MiniMax-M3", + finish_reason: str = "stop", + cached_tokens: int | None = 120, + reasoning: str | None = "생각중...", +) -> ChatCompletion: + message: dict[str, Any] = {"role": "assistant", "content": content} + if reasoning is not None: + message["reasoning_content"] = reasoning + usage: dict[str, Any] = {"prompt_tokens": 1500, "completion_tokens": 400, "total_tokens": 1900} + if cached_tokens is not None: + usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1_700_000_000, + "model": model, + "choices": [{"index": 0, "finish_reason": finish_reason, "message": message}], + "usage": usage, + } + ) + + +def make_status_error(status: int, message: str = "server error") -> openai.APIStatusError: + request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") + response = httpx2.Response(status, request=request, json={"error": message}) + if status == 429: + return openai.RateLimitError(message, response=response, body={"error": message}) + return openai.APIStatusError(message, response=response, body={"error": message}) + + +class FakeCompletions: + def __init__(self, responses: list[Any]) -> None: + self._responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + def create(self, **kwargs: Any) -> Any: + self.calls.append(kwargs) + if not self._responses: + raise AssertionError("unexpected extra call to chat.completions.create") + item = self._responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + +class FakeChat: + def __init__(self, completions: FakeCompletions) -> None: + self.completions = completions + + +class FakeClient: + def __init__(self, responses: list[Any]) -> None: + self.completions = FakeCompletions(responses) + self.chat = FakeChat(self.completions) + + @property + def calls(self) -> list[dict[str, Any]]: + return self.completions.calls + + @property + def models_called(self) -> list[str]: + return [c["model"] for c in self.calls] + + +def make_provider(responses: list[Any], **settings_overrides: Any) -> tuple[MiniMaxProvider, FakeClient]: + client = FakeClient(responses) + return MiniMaxProvider(make_settings(**settings_overrides), client=client), client + + +# ---------------------------------------------------------------------------- prompts +def test_system_prompt_has_no_dates_or_timestamps() -> None: + assert SYSTEM_PROMPT.strip() + assert not re.search(r"\d{4}-\d{2}-\d{2}", SYSTEM_PROMPT) + assert not re.search(r"\d{4}년", SYSTEM_PROMPT) + assert not re.search(r"\d{1,2}:\d{2}", SYSTEM_PROMPT) + assert not re.search(r"20\d{2}", SYSTEM_PROMPT) + for keyword in ("한국 투자자", "투자 조언", "보도에 따르면", "JSON", "반도체", "환율", "EWY", "습니다"): + assert keyword in SYSTEM_PROMPT + + +def test_system_prompt_contains_group_glossary() -> None: + for title in ("주요 지수", "미국 국채 금리", "한국 연관(EWY·ADR)", "반도체", "빅테크"): + assert title in SYSTEM_PROMPT + + +def test_system_prompt_declares_field_types_and_korean_only() -> None: + assert "한국어로만" in SYSTEM_PROMPT + for name in Report.model_fields: + assert f"- {name}: " in SYSTEM_PROMPT + assert f'"{name}":' in SYSTEM_PROMPT # skeleton example + assert "- key_drivers: 문자열 배열" in SYSTEM_PROMPT + assert "- headline: 문자열 -" in SYSTEM_PROMPT + + +def test_build_user_message_is_deterministic_and_korean() -> None: + payload = make_payload() + first = build_user_message(payload) + second = build_user_message(make_payload()) + assert first == second + for title in ( + "## 세션 정보", + "## 시세", + "### 주요 지수", + "### 미국 국채 금리", + "### 환율·달러", + "### 반도체", + ): + assert title in first + for title in ("## 히스토리 노트", "## 뉴스", "## 경제지표", "## 실적", "## 수집 누락"): + assert title in first + + +def test_build_user_message_content_and_rounding() -> None: + text = build_user_message(make_payload()) + # session block: close time converted to KST (16:00 EDT == 05:00 KST next day) + assert "2026-03-10" in text + assert "2026-03-11 05:00 KST" in text + assert "오늘 한국 장: 개장" in text + # prices rounded, percent vs points + assert "^GSPC S&P 500: 5,912.35 (+0.81%)" in text + assert "^TNX 미 10년물: 4.253 (-0.051%p)" in text + assert "애프터 920.00 (+0.82%)" in text + assert "EWY MSCI Korea ETF(EWY): 자료 없음" in text + # news: title | source | KST time; summary whitespace-collapsed and truncated + assert "Nvidia rallies ahead of earnings | CNBC | 2026-03-11 04:30 KST" in text + assert "요약: Shares rose as investors bet on strong data-center demand." in text + assert "…" in text + # events / earnings / gaps / history + assert "[발표] CPI (YoY)" in text and "실제 3.1% / 예상 3.2% / 이전 3.3%" in text + assert "[예정] FOMC 의사록" in text and "시간 미정" in text + assert "MU 마이크론 (2026-03-10 amc): EPS 실제 1.23 / 예상 1.10" in text + assert "- S&P 500 3일 연속 상승" in text + assert "- events: HTTPError" in text + + +def test_build_user_message_empty_sections() -> None: + payload = make_payload().model_copy( + update={"quotes": [], "news": [], "events": [], "earnings": [], "history_notes": [], "data_gaps": []} + ) + payload = payload.model_copy(update={"krx_open_today": False}) + text = build_user_message(payload) + assert "(시세 자료 없음)" in text + assert text.count("(없음)") == 5 + assert "오늘 한국 장: 휴장" in text + + +# ----------------------------------------------------------------------------- schema +def _walk(node: Any): + if isinstance(node, dict): + yield node + for v in node.values(): + yield from _walk(v) + elif isinstance(node, list): + for v in node: + yield from _walk(v) + + +def test_report_json_schema_has_no_titles_and_is_closed() -> None: + schema = report_json_schema() + assert all("title" not in d for d in _walk(schema)) + assert schema["additionalProperties"] is False + assert schema["type"] == "object" + assert set(schema["properties"]) == set(Report.model_fields) + fmt = report_response_format() + assert fmt["type"] == "json_schema" + assert fmt["json_schema"]["name"] == "report" + assert fmt["json_schema"]["schema"] == schema + json.dumps(schema) # serialisable + # Report itself is unchanged by the helper (deepcopy) + assert "title" in Report.model_json_schema() + + +# ------------------------------------------------------------------- extraction utils +@pytest.mark.parametrize( + "raw", + [ + json.dumps(GOOD_REPORT, ensure_ascii=False), + "먼저 지수를 본다...\n" + json.dumps(GOOD_REPORT, ensure_ascii=False), + "```json\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\n```", + "ab```\n" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "```", + # unterminated with an earlier closed one: take text after the last + "x" + json.dumps(GOOD_REPORT, ensure_ascii=False) + "\ntrailing", + # unterminated and no : fall back to first JSON object + "reasoning " + json.dumps(GOOD_REPORT, ensure_ascii=False), + "설명입니다: " + json.dumps(GOOD_REPORT, ensure_ascii=False) + " 끝.", + ], +) +def test_extract_and_parse_variants(raw: str) -> None: + text = extract_json_text(raw) + assert json.loads(text) == GOOD_REPORT + report = parse_report(raw) + assert report.headline == GOOD_REPORT["headline"] + + +def test_parse_report_coerces_string_list_fields(caplog: pytest.LogCaptureFixture) -> None: + prose = { + **GOOD_REPORT, + "key_drivers": "상승 요인: 반도체가 이끌었습니다. 하락 요인: 유가가 내렸습니다.", + "korea_implications": "- SOX 상승은 긍정적입니다.\n- 환율은 안정적입니다.\n- EWY가 올랐습니다.", + "watchlist_today": "1. 삼성전자 흐름\n2. 환율 방향", + "risk_flags": "단일 문장입니다", + "data_gaps": "events: FRED key missing", + } + with caplog.at_level("WARNING", logger="us2kr.llm.minimax"): + report = parse_report("```json\n" + json.dumps(prose, ensure_ascii=False) + "\n```") + assert report.key_drivers == ["상승 요인: 반도체가 이끌었습니다.", "하락 요인: 유가가 내렸습니다."] + assert report.korea_implications == [ + "SOX 상승은 긍정적입니다.", + "환율은 안정적입니다.", + "EWY가 올랐습니다.", + ] + assert report.watchlist_today == ["삼성전자 흐름", "환율 방향"] + assert report.risk_flags == ["단일 문장입니다"] + assert report.data_gaps == ["events: FRED key missing"] + assert report.headline == GOOD_REPORT["headline"] + assert any("coerced" in r.getMessage() for r in caplog.records) + + +def test_coerce_report_data_leaves_valid_and_non_dict_alone() -> None: + data, coerced = coerce_report_data(dict(GOOD_REPORT)) + assert coerced == [] and data == GOOD_REPORT + assert coerce_report_data(["not", "a", "dict"]) == (["not", "a", "dict"], []) + # wrong non-string types are not coerced -> validation error surfaces + with pytest.raises(ValidationError): + parse_report(json.dumps({**GOOD_REPORT, "key_drivers": 42})) + + +def test_extract_json_text_raises_without_object() -> None: + with pytest.raises(ValueError): + extract_json_text("only thinking, no answer") + with pytest.raises(ValueError): + extract_json_text("no json here") + + +# ----------------------------------------------------------------------------- provider +def test_provider_requires_api_key_when_no_client() -> None: + with pytest.raises(LLMError): + MiniMaxProvider(make_settings(MINIMAX_API_KEY="")) + + +def test_provider_builds_openai_client(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + class DummyOpenAI: + def __init__(self, **kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(openai, "OpenAI", DummyOpenAI) + settings = make_settings(MINIMAX_TIMEOUT_SECONDS=42.0) + provider = MiniMaxProvider(settings) + assert provider.models == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] + assert captured["api_key"] == "test-key-not-real" + assert captured["base_url"] == settings.minimax_base_url + assert captured["timeout"] == 42.0 + assert captured["max_retries"] == 2 + + +def test_summarize_clean_json(caplog: pytest.LogCaptureFixture) -> None: + provider, client = make_provider([make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))]) + with caplog.at_level("INFO", logger="us2kr.llm.minimax"): + report, model = provider.summarize(make_payload()) + assert model == "MiniMax-M3" + assert report == Report.model_validate(GOOD_REPORT) + assert len(client.calls) == 1 + call = client.calls[0] + assert call["model"] == "MiniMax-M3" + assert call["max_completion_tokens"] == 4096 + assert call["response_format"]["type"] == "json_schema" + assert call["extra_body"]["reasoning_split"] is True + assert call["extra_body"]["thinking"] == {"type": "adaptive"} + assert [m["role"] for m in call["messages"]] == ["system", "user"] + assert call["messages"][0]["content"] == SYSTEM_PROMPT + assert "## 시세" in call["messages"][1]["content"] + usage_logs = [r.getMessage() for r in caplog.records if "prompt_tokens=1500" in r.getMessage()] + assert usage_logs and "cached_tokens=120" in usage_logs[0] and "completion_tokens=400" in usage_logs[0] + # never log the key or the prompt body + assert all("test-key-not-real" not in r.getMessage() for r in caplog.records) + assert all("## 시세" not in r.getMessage() for r in caplog.records) + + +def test_summarize_think_wrapped_and_code_fence() -> None: + body = json.dumps(GOOD_REPORT, ensure_ascii=False) + provider, _ = make_provider( + [make_completion(f"추론...\n```json\n{body}\n```", reasoning=None)] + ) + report, model = provider.summarize(make_payload()) + assert model == "MiniMax-M3" + assert report.headline == GOOD_REPORT["headline"] + + +def test_summarize_no_thinking_param_for_m2_models() -> None: + provider, client = make_provider( + [make_completion(json.dumps(GOOD_REPORT), model="MiniMax-M2.7")], MINIMAX_MODEL="MiniMax-M2.7" + ) + provider.summarize(make_payload()) + assert "thinking" not in client.calls[0]["extra_body"] + assert client.calls[0]["extra_body"]["reasoning_split"] is True + + +def test_invalid_json_then_repaired_on_retry() -> None: + broken = '{"headline": "x", "market_overview": 12' # truncated / invalid + provider, client = make_provider( + [make_completion(broken), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))] + ) + report, model = provider.summarize(make_payload()) + assert model == "MiniMax-M3" + assert report.headline == GOOD_REPORT["headline"] + assert client.models_called == ["MiniMax-M3", "MiniMax-M3"] + repair_messages = client.calls[1]["messages"] + assert [m["role"] for m in repair_messages] == ["system", "user", "assistant", "user"] + assert repair_messages[2]["content"] == broken + assert "검증에 실패" in repair_messages[3]["content"] + + +def test_schema_violation_then_repaired() -> None: + missing_fields = json.dumps({"headline": "only headline"}) + provider, client = make_provider( + [make_completion(missing_fields), make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False))] + ) + report, _ = provider.summarize(make_payload()) + assert report.market_overview == GOOD_REPORT["market_overview"] + assert "market_overview" in client.calls[1]["messages"][3]["content"] + + +def test_repair_failure_moves_to_next_model() -> None: + provider, client = make_provider( + [ + make_completion("not json"), + make_completion("still not json"), + make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), + ] + ) + _, model = provider.summarize(make_payload()) + assert model == "MiniMax-M2.7" + assert client.models_called == ["MiniMax-M3", "MiniMax-M3", "MiniMax-M2.7"] + + +def test_api_status_error_falls_back_to_second_model() -> None: + provider, client = make_provider( + [ + make_status_error(500), + make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), + ] + ) + report, model = provider.summarize(make_payload()) + assert model == "MiniMax-M2.7" + assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7"] + assert report.headline == GOOD_REPORT["headline"] + + +def test_rate_limit_and_connection_errors_fall_through() -> None: + request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") + provider, client = make_provider( + [ + make_status_error(429, "rate limited"), + openai.APITimeoutError(request=request), + make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.5"), + ] + ) + _, model = provider.summarize(make_payload()) + assert model == "MiniMax-M2.5" + assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] + + +def test_finish_reason_length_moves_to_next_model() -> None: + provider, client = make_provider( + [ + make_completion('{"headline": "cut off', finish_reason="length"), + make_completion(json.dumps(GOOD_REPORT, ensure_ascii=False), model="MiniMax-M2.7"), + ] + ) + _, model = provider.summarize(make_payload()) + assert model == "MiniMax-M2.7" + assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7"] + + +def test_all_models_fail_raises_llm_error_with_attempts() -> None: + request = httpx2.Request("POST", "https://api.minimax.io/v1/chat/completions") + provider, client = make_provider( + [ + make_status_error(503, "unavailable"), + make_completion(None), # empty content + openai.APIConnectionError(request=request), + ] + ) + with pytest.raises(LLMError) as excinfo: + provider.summarize(make_payload()) + msg = str(excinfo.value) + assert "MiniMax-M3" in msg and "503" in msg + assert "MiniMax-M2.7" in msg and "empty content" in msg + assert "MiniMax-M2.5" in msg and "APIConnectionError" in msg + assert client.models_called == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"] + + +def test_unexpected_exception_propagates() -> None: + provider, _ = make_provider([RuntimeError("programming error")]) + with pytest.raises(RuntimeError): + provider.summarize(make_payload()) + + +# ---------------------------------------------------------------------------- summarize +def test_build_provider_returns_minimax(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(openai, "OpenAI", lambda **kwargs: object()) + provider = build_provider(make_settings()) + assert isinstance(provider, MiniMaxProvider) + + +def test_summarize_payload_merges_data_gaps() -> None: + class StubProvider: + def summarize(self, payload: MarketPayload) -> tuple[Report, str]: + report = Report.model_validate( + {**GOOD_REPORT, "data_gaps": ["news: TimeoutError", "events: HTTPError"]} + ) + return report, "stub-model" + + payload = make_payload().model_copy(update={"data_gaps": ["prices: ^VIX missing", "events: HTTPError"]}) + report, model = summarize_payload(payload, StubProvider()) + assert model == "stub-model" + assert report.data_gaps == ["prices: ^VIX missing", "events: HTTPError", "news: TimeoutError"] + + +def test_summarize_payload_no_gaps_keeps_report() -> None: + class StubProvider: + def summarize(self, payload: MarketPayload) -> tuple[Report, str]: + return Report.model_validate({**GOOD_REPORT, "data_gaps": []}), "m" + + payload = make_payload().model_copy(update={"data_gaps": []}) + report, _ = summarize_payload(payload, StubProvider()) + assert report.data_gaps == [] diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py new file mode 100644 index 0000000..5eebe48 --- /dev/null +++ b/tests/test_logging_setup.py @@ -0,0 +1,163 @@ +"""logging_setup tests: masking, rotation handler, idempotency.""" + +from __future__ import annotations + +import logging + +import pytest + +from us2kr.config import Settings +from us2kr.logging_setup import ( + _HANDLER_TAG, + SecretMaskingFilter, + SecretMaskingFormatter, + mask_secrets, + register_secret_values, + setup_logging, +) + +SK = "sk-cp-abcdefghijklmnopqrstuvwxyz" +BOT = "123456789:AAH-abcdefghijklmnopqrstuvwxyz0123" + + +@pytest.fixture(autouse=True) +def _reset_registered_secrets(): + yield + register_secret_values() + + +@pytest.fixture +def root_cleanup(): + root = logging.getLogger() + before_level = root.level + yield root + for h in list(root.handlers): + if getattr(h, _HANDLER_TAG, False): + root.removeHandler(h) + h.close() + root.setLevel(before_level) + + +def our_handlers(root): + return [h for h in root.handlers if getattr(h, _HANDLER_TAG, False)] + + +def test_mask_secrets_patterns(): + assert mask_secrets(f"key={SK} token={BOT} other=abc") == "key=*** token=*** other=abc" + assert mask_secrets("chat_id=-1001234 ok") == "chat_id=-1001234 ok" # not a token shape + assert mask_secrets("sk-ab") == "sk-ab" # too short to be a key + + +def test_filter_masks_msg_and_args(): + rec = logging.LogRecord("x", logging.INFO, __file__, 1, f"Bearer {SK} for %s and %s", (BOT, 42), None) + assert SecretMaskingFilter().filter(rec) is True + assert rec.msg == "Bearer *** for %s and %s" + assert rec.args == ("***", 42) + assert rec.getMessage() == "Bearer *** for *** and 42" + + +def test_setup_logging_creates_file_handler_and_masks(tmp_path, root_cleanup, capsys): + settings = Settings(_env_file=None, US2KR_LOG_DIR=str(tmp_path / "logs"), US2KR_LOG_LEVEL="DEBUG") + setup_logging(settings) + + root = root_cleanup + assert root.level == logging.DEBUG + handlers = our_handlers(root) + assert len(handlers) == 2 + kinds = {type(h).__name__ for h in handlers} + assert kinds == {"StreamHandler", "TimedRotatingFileHandler"} + file_handler = next(h for h in handlers if type(h).__name__ == "TimedRotatingFileHandler") + assert file_handler.when == "MIDNIGHT" and file_handler.backupCount == 14 + + logging.getLogger("us2kr.test").info("using key %s", SK) + try: + raise RuntimeError(f"auth failed for {BOT}") + except RuntimeError: + logging.getLogger("us2kr.test").exception("boom") + for h in handlers: + h.flush() + + text = (tmp_path / "logs" / "us2kr.log").read_text(encoding="utf-8") + assert "using key ***" in text + assert SK not in text and BOT not in text # traceback text masked by the formatter too + assert "RuntimeError: auth failed for ***" in text + assert SK not in capsys.readouterr().err + + +def test_setup_logging_is_idempotent_and_quiets_noisy_loggers(tmp_path, root_cleanup): + settings = Settings(_env_file=None, US2KR_LOG_DIR=str(tmp_path / "logs"), US2KR_LOG_LEVEL="warning") + setup_logging(settings) + setup_logging(settings) + assert len(our_handlers(root_cleanup)) == 2 + assert root_cleanup.level == logging.WARNING + for name in ("httpx", "apscheduler.executors", "yfinance", "peewee"): + assert logging.getLogger(name).level == logging.WARNING + + +def test_setup_logging_unknown_level_defaults_to_info(tmp_path, root_cleanup): + settings = Settings(_env_file=None, US2KR_LOG_DIR=str(tmp_path / "logs"), US2KR_LOG_LEVEL="loud") + setup_logging(settings) + assert root_cleanup.level == logging.INFO + + +# ------------------------------------------------------------------ URL query secrets / exact values +FRED_KEY = "0123456789abcdef0123456789abcdef" + + +def test_mask_secrets_url_query_parameter(): + msg = ( + "400 Client Error: Bad Request for url: https://api.stlouisfed.org/fred/releases/dates" + f"?realtime_start=2026-08-24&limit=1000&api_key={FRED_KEY}&file_type=json" + ) + out = mask_secrets(msg) + assert FRED_KEY not in out + assert "api_key=***&file_type=json" in out + assert "limit=1000" in out # non-secret params untouched + # urllib3 ConnectionError style (path only, key last, no trailing param) and other param names + conn = ( + f"Max retries exceeded with url: /fred/releases/dates?file_type=json&api_key={FRED_KEY} (Caused by X)" + ) + expected = "Max retries exceeded with url: /fred/releases/dates?file_type=json&api_key=*** (Caused by X)" + assert mask_secrets(conn) == expected + assert mask_secrets("https://x/api?token=abc123&x=1") == "https://x/api?token=***&x=1" + assert mask_secrets("https://x/api?APIKEY=abc123") == "https://x/api?APIKEY=***" + # a bare word 'key=' outside a query string is not a URL secret + assert mask_secrets("chat_id=-1001234 key=value") == "chat_id=-1001234 key=value" + + +def test_mask_secrets_registered_exact_values(): + register_secret_values("plainvalue-without-shape-1234", None, "", "short") + out = mask_secrets("x plainvalue-without-shape-1234 y short") + assert out == "x *** y short" # too-short values are not registered (would shred normal text) + register_secret_values() + assert "plainvalue-without-shape-1234" in mask_secrets("x plainvalue-without-shape-1234 y") + + +def test_formatter_masks_requests_style_traceback_with_fred_key(): + formatter = SecretMaskingFormatter("%(message)s") + url = f"https://api.stlouisfed.org/fred/releases/dates?realtime_start=2026-08-24&api_key={FRED_KEY}&file_type=json" + try: + raise ConnectionError(f"Max retries exceeded with url: {url}") + except ConnectionError: + rec = logging.LogRecord("x", logging.ERROR, __file__, 1, "collector %s failed", ("events",), None) + import sys + + rec.exc_info = sys.exc_info() + text = formatter.format(rec) + assert FRED_KEY not in text + assert "api_key=***&file_type=json" in text + assert "collector events failed" in text + + +def test_setup_logging_registers_configured_secret_values(tmp_path, root_cleanup): + odd_key = "FREDKEY-with-unusual-shape-XYZ" # matches no regex shape + settings = Settings( + _env_file=None, US2KR_LOG_DIR=str(tmp_path / "logs"), FRED_API_KEY=odd_key, MINIMAX_API_KEY=SK + ) + setup_logging(settings) + logging.getLogger("us2kr.test").error("failed with %s and %s", odd_key, SK) + for h in our_handlers(root_cleanup): + h.flush() + text = (tmp_path / "logs" / "us2kr.log").read_text(encoding="utf-8") + assert odd_key not in text and SK not in text + assert "failed with *** and ***" in text diff --git a/tests/test_market_time.py b/tests/test_market_time.py new file mode 100644 index 0000000..2260349 --- /dev/null +++ b/tests/test_market_time.py @@ -0,0 +1,285 @@ +"""Tests for us2kr.market_time.MarketClock (DST, holidays, early close, weekends, KRX).""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest +from freezegun import freeze_time + +from us2kr.market_time import MarketClock + +ET = ZoneInfo("America/New_York") +KST = ZoneInfo("Asia/Seoul") + + +def kst(y: int, m: int, d: int, hh: int, mm: int = 0) -> datetime: + return datetime(y, m, d, hh, mm, tzinfo=KST) + + +def et(y: int, m: int, d: int, hh: int, mm: int = 0) -> datetime: + return datetime(y, m, d, hh, mm, tzinfo=ET) + + +@pytest.fixture(scope="module") +def clock() -> MarketClock: + return MarketClock(run_offset_minutes=75, anchor=date(2026, 8, 25)) + + +# --------------------------------------------------------------------------- close / briefing + + +def test_edt_day_close_and_briefing(clock: MarketClock) -> None: + session = date(2026, 6, 15) + close = clock.us_session_close(session) + assert close.tzinfo is not None and close.tzinfo.key == "America/New_York" + assert close == et(2026, 6, 15, 16, 0) + assert close.utcoffset() == timedelta(hours=-4) # EDT + assert close.astimezone(KST) == kst(2026, 6, 16, 5, 0) + assert clock.briefing_time_for(session) == kst(2026, 6, 16, 6, 15) + assert clock.briefing_time_for(session).tzinfo.key == "Asia/Seoul" + assert clock.krx_date_for(session) == date(2026, 6, 16) + + +def test_est_day_close_and_briefing(clock: MarketClock) -> None: + session = date(2026, 1, 15) + close = clock.us_session_close(session) + assert close == et(2026, 1, 15, 16, 0) + assert close.utcoffset() == timedelta(hours=-5) # EST + assert close.astimezone(KST) == kst(2026, 1, 16, 6, 0) + assert clock.briefing_time_for(session) == kst(2026, 1, 16, 7, 15) + assert clock.krx_date_for(session) == date(2026, 1, 16) + + +def test_custom_offset() -> None: + clock = MarketClock(run_offset_minutes=30, anchor=date(2026, 8, 25)) + assert clock.briefing_time_for(date(2026, 6, 15)) == kst(2026, 6, 16, 5, 30) + + +def test_negative_offset_rejected() -> None: + with pytest.raises(ValueError): + MarketClock(run_offset_minutes=-1, anchor=date(2026, 8, 25)) + + +# --------------------------------------------------------------------------- holidays / early close + + +@pytest.mark.parametrize("holiday", [date(2026, 7, 3), date(2026, 11, 26), date(2026, 1, 1)]) +def test_us_holiday(clock: MarketClock, holiday: date) -> None: + assert clock.is_us_session(holiday) is False + with pytest.raises(ValueError): + clock.us_session_close(holiday) + with pytest.raises(ValueError): + clock.briefing_time_for(holiday) + + +def test_previous_session_skips_holiday_and_weekend(clock: MarketClock) -> None: + # 07-03 (Fri) observed Independence Day, 07-04/05 weekend -> Monday's previous session is Thu 07-02 + assert clock.previous_us_session(date(2026, 7, 6)) == date(2026, 7, 2) + assert clock.previous_us_session(date(2026, 7, 3)) == date(2026, 7, 2) + # strictly before: a session date returns the one before it + assert clock.previous_us_session(date(2026, 7, 2)) == date(2026, 7, 1) + assert clock.next_us_session(date(2026, 7, 2)) == date(2026, 7, 6) + + +def test_early_close_day(clock: MarketClock) -> None: + session = date(2026, 11, 27) # day after Thanksgiving: 13:00 ET close + assert clock.is_us_session(session) + close = clock.us_session_close(session) + assert close == et(2026, 11, 27, 13, 0) + assert close.astimezone(KST) == kst(2026, 11, 28, 3, 0) + assert clock.briefing_time_for(session) == kst(2026, 11, 28, 4, 15) + assert clock.krx_date_for(session) == date(2026, 11, 28) + + +# --------------------------------------------------------------------------- weekend + + +def test_weekend_not_session(clock: MarketClock) -> None: + assert clock.is_us_session(date(2026, 8, 22)) is False # Saturday + assert clock.is_us_session(date(2026, 8, 23)) is False # Sunday + assert clock.is_krx_session(date(2026, 8, 22)) is False + assert clock.is_us_session(date(2026, 8, 21)) is True + # Sunday afternoon -> Friday is the latest completed session + assert clock.latest_completed_session(kst(2026, 8, 23, 15, 0)) == date(2026, 8, 21) + + +# --------------------------------------------------------------------------- DST transitions + + +def test_dst_spring_forward_2026_03_08(clock: MarketClock) -> None: + # Friday before the switch is still EST (close 06:00 KST -> briefing 07:15) + assert clock.briefing_time_for(date(2026, 3, 6)) == kst(2026, 3, 7, 7, 15) + # Monday after the switch is EDT (close 05:00 KST -> briefing 06:15) + assert clock.briefing_time_for(date(2026, 3, 9)) == kst(2026, 3, 10, 6, 15) + assert clock.is_us_session(date(2026, 3, 8)) is False + # asking on the switch day itself resolves to Monday's session + session, run_at = clock.next_briefing(kst(2026, 3, 8, 12, 0)) + assert (session, run_at) == (date(2026, 3, 9), kst(2026, 3, 10, 6, 15)) + + +def test_dst_fall_back_2026_11_01(clock: MarketClock) -> None: + assert clock.briefing_time_for(date(2026, 10, 30)) == kst(2026, 10, 31, 6, 15) # EDT + assert clock.briefing_time_for(date(2026, 11, 2)) == kst(2026, 11, 3, 7, 15) # EST + assert clock.is_us_session(date(2026, 11, 1)) is False + session, run_at = clock.next_briefing(kst(2026, 11, 1, 12, 0)) + assert (session, run_at) == (date(2026, 11, 2), kst(2026, 11, 3, 7, 15)) + + +# --------------------------------------------------------------------------- next_briefing + + +def test_next_briefing_across_weekend(clock: MarketClock) -> None: + # Friday 16:30 ET (= Sat 05:30 KST): Friday's briefing (Sat 06:15 KST = 17:15 ET) is still ahead + session, run_at = clock.next_briefing(et(2026, 8, 21, 16, 30)) + assert session == date(2026, 8, 21) + assert run_at == kst(2026, 8, 22, 6, 15) + assert run_at.tzinfo.key == "Asia/Seoul" + # right after it fires, the next one is Monday's session -> Tuesday KST + session2, run_at2 = clock.next_briefing(run_at) # boundary: equal is NOT strictly after + assert (session2, run_at2) == (date(2026, 8, 24), kst(2026, 8, 25, 6, 15)) + session3, run_at3 = clock.next_briefing(run_at + timedelta(minutes=1)) + assert (session3, run_at3) == (date(2026, 8, 24), kst(2026, 8, 25, 6, 15)) + + +def test_next_briefing_before_close_same_day(clock: MarketClock) -> None: + session, run_at = clock.next_briefing(et(2026, 8, 24, 10, 0)) + assert (session, run_at) == (date(2026, 8, 24), kst(2026, 8, 25, 6, 15)) + + +def test_next_briefing_over_holiday(clock: MarketClock) -> None: + # Thu 07-02 briefing fired (Fri 07-03 06:15 KST); 07-03 holiday + weekend -> Mon 07-06 session + session, run_at = clock.next_briefing(kst(2026, 7, 3, 7, 0)) + assert (session, run_at) == (date(2026, 7, 6), kst(2026, 7, 7, 6, 15)) + + +@freeze_time("2026-08-24 21:00:00") # UTC = 17:00 ET Monday = 06:00 KST Tuesday +def test_next_briefing_with_frozen_now(clock: MarketClock) -> None: + now = datetime.now(tz=KST) + assert now == kst(2026, 8, 25, 6, 0) + session, run_at = clock.next_briefing(now) + assert (session, run_at) == (date(2026, 8, 24), kst(2026, 8, 25, 6, 15)) + + +# --------------------------------------------------------------------------- latest_completed_session + + +def test_latest_completed_session_around_close(clock: MarketClock) -> None: + assert clock.latest_completed_session(et(2026, 8, 24, 15, 59)) == date(2026, 8, 21) + assert clock.latest_completed_session(et(2026, 8, 24, 16, 0)) == date(2026, 8, 24) # inclusive + assert clock.latest_completed_session(et(2026, 8, 24, 16, 1)) == date(2026, 8, 24) + # early-close day: 13:30 ET is already after the 13:00 close + assert clock.latest_completed_session(et(2026, 11, 27, 13, 30)) == date(2026, 11, 27) + assert clock.latest_completed_session(et(2026, 11, 27, 12, 30)) == date(2026, 11, 25) + + +def test_latest_completed_session_accepts_any_tz(clock: MarketClock) -> None: + # 06:00 KST Tuesday == 17:00 ET Monday + assert clock.latest_completed_session(kst(2026, 8, 25, 6, 0)) == date(2026, 8, 24) + assert clock.latest_completed_session(kst(2026, 8, 25, 4, 0)) == date(2026, 8, 21) + + +def test_naive_datetime_rejected(clock: MarketClock) -> None: + naive = datetime(2026, 8, 25, 6, 0) # noqa: DTZ001 - deliberately naive + with pytest.raises(ValueError): + clock.latest_completed_session(naive) + with pytest.raises(ValueError): + clock.next_briefing(naive) + with pytest.raises(ValueError): + clock.catchup_candidate(naive, 3) + + +# --------------------------------------------------------------------------- catch-up + + +def test_catchup_candidate_inside_window(clock: MarketClock) -> None: + # Monday 08-24 briefing fires 06:15 KST Tuesday; daemon restarts at 07:00 -> catch up + assert clock.catchup_candidate(kst(2026, 8, 25, 7, 0), window_hours=3) == date(2026, 8, 24) + assert clock.catchup_candidate(kst(2026, 8, 25, 6, 15), window_hours=3) == date(2026, 8, 24) # inclusive + + +def test_catchup_candidate_outside_window(clock: MarketClock) -> None: + assert clock.catchup_candidate(kst(2026, 8, 25, 9, 16), window_hours=3) is None # 3h01m later + assert clock.catchup_candidate(kst(2026, 8, 25, 15, 0), window_hours=3) is None + # between close (05:00) and briefing (06:15): nothing was missed yet + assert clock.catchup_candidate(kst(2026, 8, 25, 5, 30), window_hours=3) is None + # weekend afternoon: Friday's briefing is far in the past + assert clock.catchup_candidate(kst(2026, 8, 23, 15, 0), window_hours=3) is None + + +def test_catchup_candidate_wide_window_reaches_previous_session(clock: MarketClock) -> None: + # 05:30 KST Tuesday: Monday's briefing not yet due, Friday's (Sat 06:15) is ~71.25h ago + assert clock.catchup_candidate(kst(2026, 8, 25, 5, 30), window_hours=72) == date(2026, 8, 21) + + +# --------------------------------------------------------------------------- KRX + + +def test_krx_chuseok_holiday(clock: MarketClock) -> None: + # 추석 2026: 09-24(목), 09-25(금) KRX closed while XNYS trades + assert clock.is_krx_session(date(2026, 9, 23)) is True + assert clock.is_krx_session(date(2026, 9, 24)) is False + assert clock.is_krx_session(date(2026, 9, 25)) is False + assert clock.is_us_session(date(2026, 9, 24)) is True + # briefing for the Wed 09-23 US session lands on the Korean holiday + assert clock.krx_date_for(date(2026, 9, 23)) == date(2026, 9, 24) + assert clock.is_krx_session(clock.krx_date_for(date(2026, 9, 23))) is False + # a normal day + assert clock.krx_date_for(date(2026, 8, 24)) == date(2026, 8, 25) + assert clock.is_krx_session(date(2026, 8, 25)) is True + + +def test_krx_only_holiday_does_not_affect_us_schedule(clock: MarketClock) -> None: + # 2026-10-09 한글날 (Fri): KRX closed, US open -> briefing still computed + assert clock.is_krx_session(date(2026, 10, 9)) is False + assert clock.is_us_session(date(2026, 10, 9)) is True + assert clock.briefing_time_for(date(2026, 10, 8)) == kst(2026, 10, 9, 6, 15) + + +def test_next_krx_session(clock: MarketClock) -> None: + assert clock.next_krx_session(date(2026, 8, 22)) == date(2026, 8, 24) # Sat -> Mon + assert clock.next_krx_session(date(2026, 8, 25)) == date(2026, 8, 25) # session day returns itself + assert clock.next_krx_session(date(2026, 9, 24)) == date(2026, 9, 28) # 추석 -> following Monday + assert clock.next_krx_session(date(2026, 10, 9)) == date(2026, 10, 12) # 한글날(Fri) -> Mon + assert clock.next_krx_session(date(2026, 12, 25)) == date(2026, 12, 28) + + +def test_krx_known_adhoc_closures(clock: MarketClock) -> None: + # exchange_calendars 4.13.2 marks 2026-06-03 (지방선거) as a session; KRX closes on election days. + assert clock.krx_date_for(date(2026, 6, 2)) == date(2026, 6, 3) + assert clock.is_krx_session(date(2026, 6, 3)) is False + assert clock.is_krx_session(date(2026, 6, 4)) is True + assert clock.next_krx_session(date(2026, 6, 3)) == date(2026, 6, 4) + assert clock.is_krx_session(date(2025, 10, 10)) is False # 임시공휴일 + # past election days are already in the library data + assert clock.is_krx_session(date(2025, 6, 3)) is False + + +def test_krx_extra_holidays_override() -> None: + c = MarketClock(anchor=date(2026, 8, 25), extra_krx_holidays=[date(2026, 8, 20), date(2026, 8, 21)]) + assert c.is_krx_session(date(2026, 8, 20)) is False + assert c.next_krx_session(date(2026, 8, 20)) == date(2026, 8, 24) # Thu/Fri closed -> Mon + assert MarketClock(anchor=date(2026, 8, 25)).is_krx_session(date(2026, 8, 20)) is True + # override days do not change the US-driven schedule + assert c.briefing_time_for(date(2026, 8, 19)) == kst(2026, 8, 20, 6, 15) + + +# --------------------------------------------------------------------------- calendar window + + +def test_out_of_window_date_triggers_rebuild() -> None: + narrow = MarketClock(anchor=date(2026, 8, 25), years_back=0, years_ahead=0) + assert narrow.is_us_session(date(2026, 6, 15)) is True + # 2028-01-03 is outside the 2026-only window: must rebuild instead of raising + assert narrow.is_us_session(date(2028, 1, 3)) is True + assert narrow.is_us_session(date(2028, 1, 1)) is False + assert narrow.us_session_close(date(2028, 1, 3)) == et(2028, 1, 3, 16, 0) + + +def test_default_anchor_is_today() -> None: + clock = MarketClock() + today = datetime.now(tz=KST).date() + # today's date is always resolvable without a rebuild + assert isinstance(clock.is_us_session(today), bool) + assert isinstance(clock.next_briefing(datetime.now(tz=KST))[1], datetime) diff --git a/tests/test_news.py b/tests/test_news.py new file mode 100644 index 0000000..97963e0 --- /dev/null +++ b/tests/test_news.py @@ -0,0 +1,168 @@ +"""Tests for us2kr.collectors.news (no network: requests.get is mocked).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +import requests + +from us2kr.collectors import news +from us2kr.collectors.news import fetch_news, normalize_title, strip_html + +SINCE = datetime(2026, 8, 25, 12, 0, tzinfo=UTC) + + +def _rss(*items: str) -> bytes: + body = "".join(items) + return ( + 'Feed' + body + ).encode() + b"" + + +def _item(title: str, link: str, when: datetime, summary: str = "") -> str: + pub = when.strftime("%a, %d %b %Y %H:%M:%S GMT") + return ( + f"{title}{link}" + f"{pub}" + ) + + +class _Resp: + def __init__(self, content: bytes, status: int = 200): + self.content = content + self.status_code = status + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} error") + + +@pytest.fixture +def two_feeds(monkeypatch): + feeds = [("Feed A", "https://a.example/rss"), ("Feed B", "https://b.example/rss")] + monkeypatch.setattr(news, "FEEDS", feeds) + + +def test_since_filter_dedup_sort_and_tz(two_feeds, mocker): + fresh = SINCE + timedelta(hours=3) + fresher = SINCE + timedelta(hours=5) + stale = SINCE - timedelta(hours=1) + feed_a = _rss( + _item( + "Fed holds rates steady!", + "https://a.example/1", + fresh, + "

Rates unchanged & steady

", + ), + _item("Old story", "https://a.example/old", stale), + _item("Nvidia jumps 5%", "https://a.example/2", fresher), + ) + feed_b = _rss( + _item("Fed Holds Rates Steady", "https://b.example/9", fresh), # dup by normalised title + _item("Different title, same link", "https://a.example/2/", fresh), # dup by link (trailing slash) + _item("Oil slips", "https://b.example/3", fresh + timedelta(minutes=1)), + ) + mocker.patch.object( + news.requests, + "get", + side_effect=lambda url, **kw: _Resp(feed_a if "a.example" in url else feed_b), + ) + + items = fetch_news(SINCE, max_items=40) + + titles = [i.title for i in items] + assert titles == ["Nvidia jumps 5%", "Oil slips", "Fed holds rates steady!"] + assert all(i.published.tzinfo is not None and i.published.utcoffset() == timedelta(0) for i in items) + assert items[0].published > items[1].published > items[2].published + fed = items[-1] + assert fed.summary == "Rates unchanged & steady" + assert fed.source == "Feed A" + # requests called with UA header + timeout for each feed + for call in news.requests.get.call_args_list: + assert call.kwargs["timeout"] == news.FEED_TIMEOUT_SECONDS + assert "User-Agent" in call.kwargs["headers"] + + +def test_max_items_cap(two_feeds, mocker): + items_xml = [ + _item(f"Story {i}", f"https://a.example/{i}", SINCE + timedelta(minutes=i)) for i in range(10) + ] + mocker.patch.object(news.requests, "get", return_value=_Resp(_rss(*items_xml))) + items = fetch_news(SINCE, max_items=3) + assert len(items) == 3 + assert [i.title for i in items] == ["Story 9", "Story 8", "Story 7"] + + +def test_single_feed_failure_is_isolated(two_feeds, mocker): + good = _rss(_item("Survivor", "https://b.example/1", SINCE + timedelta(hours=1))) + + def fake_get(url, **kw): + if "a.example" in url: + raise requests.ConnectionError("boom") + return _Resp(good) + + mocker.patch.object(news.requests, "get", side_effect=fake_get) + items = fetch_news(SINCE) + assert [i.title for i in items] == ["Survivor"] + + +def test_http_error_and_garbage_are_isolated(two_feeds, mocker): + good = _rss(_item("Survivor", "https://b.example/1", SINCE + timedelta(hours=1))) + mocker.patch.object( + news.requests, + "get", + side_effect=lambda url, **kw: _Resp(b"<<No datehttps://a.example/nd", + "No linkTue, 25 Aug 2026 20:00:00 GMT", + _item("Kept", "https://a.example/k", SINCE + timedelta(hours=1)), + ) + mocker.patch.object(news.requests, "get", return_value=_Resp(xml)) + assert [i.title for i in fetch_news(SINCE)] == ["Kept"] + + +def test_naive_since_is_treated_as_utc(two_feeds, mocker): + xml = _rss(_item("Kept", "https://a.example/k", SINCE + timedelta(hours=1))) + mocker.patch.object(news.requests, "get", return_value=_Resp(xml)) + assert len(fetch_news(SINCE.replace(tzinfo=None))) == 1 + assert len(fetch_news((SINCE + timedelta(hours=2)).replace(tzinfo=None))) == 0 + + +def test_feedparser_receives_bytes(two_feeds, mocker): + xml = _rss(_item("Kept", "https://a.example/k", SINCE + timedelta(hours=1))) + mocker.patch.object(news.requests, "get", return_value=_Resp(xml)) + spy = mocker.spy(news.feedparser, "parse") + fetch_news(SINCE) + assert spy.call_count == 2 + assert all(isinstance(c.args[0], bytes) for c in spy.call_args_list) + + +def test_strip_html_truncates_and_unescapes(): + assert strip_html("

A & B


C") == "A & B C" + long = "word " * 100 + out = strip_html(f"
{long}
", max_chars=50) + assert len(out) <= 50 and out.endswith("…") + assert strip_html(None) == "" + + +def test_normalize_title(): + assert normalize_title("Fed Holds Rates, Steady!") == normalize_title("fed holds rates steady") diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..b906896 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,478 @@ +"""Pipeline tests: every collaborator is monkeypatched on us2kr.pipeline; no network, no real DB.""" + +from __future__ import annotations + +from datetime import date, datetime +from zoneinfo import ZoneInfo + +import pytest +from _orchestration_stubs import FakeClock, FakeDeliverer, FakeStore + +from us2kr import pipeline +from us2kr.config import Settings +from us2kr.llm.base import LLMError +from us2kr.market_time import MarketClock +from us2kr.models import EarningsItem, EconEvent, NewsItem, Quote, Report + +KST = ZoneInfo("Asia/Seoul") +ET = ZoneInfo("America/New_York") +SESSION = date(2026, 8, 25) # Tuesday +NOW = datetime(2026, 8, 26, 6, 15, tzinfo=KST) + + +def make_settings(tmp_path, **overrides) -> Settings: + values = { + "US2KR_DATA_DIR": str(tmp_path / "data"), + "US2KR_REPORTS_DIR": str(tmp_path / "reports"), + "US2KR_LOG_DIR": str(tmp_path / "logs"), + "MINIMAX_API_KEY": "sk-test-not-a-real-key", + } + values.update(overrides) + return Settings(_env_file=None, **values) + + +def sample_quotes() -> list[Quote]: + return [ + Quote( + symbol="^GSPC", name="S&P 500", group="indices", close=6500.0, prev_close=6450.0, change_pct=0.78 + ), + Quote(symbol="NVDA", name="엔비디아", group="semis", close=180.0, prev_close=175.0, change_pct=2.86), + ] + + +def sample_news() -> list[NewsItem]: + return [ + NewsItem( + title="Stocks rally", + link="https://example.com/1", + source="CNBC", + published=datetime(2026, 8, 25, 17, 0, tzinfo=ET), + ) + ] + + +def sample_report() -> Report: + return Report( + headline="반도체 강세에 미 증시 상승", + market_overview="S&P 500이 0.8% 올랐다.", + key_drivers=["엔비디아 강세"], + rates_fx_commodities="10년물 금리 소폭 하락.", + korea_implications=["SOX 강세 -> 국내 반도체 우호적"], + watchlist_today=["삼성전자 실적"], + risk_flags=["환율 변동성"], + ) + + +class Env: + """Bundle of fakes wired into us2kr.pipeline for one test.""" + + def __init__(self, monkeypatch, tmp_path, **settings_overrides): + self.settings = make_settings(tmp_path, **settings_overrides) + self.clock = FakeClock(latest=SESSION) + self.store = FakeStore(notes=["S&P 500 3일 연속 상승"]) + self.deliverer = FakeDeliverer("file") + self.deliverers = [self.deliverer] + self.calls: dict[str, int] = {} + self.summarize_payloads: list = [] + + def count(name): + self.calls[name] = self.calls.get(name, 0) + 1 + + def fetch_quotes(session, tickers=None): + count("prices") + return sample_quotes() + + def fetch_news(since, max_items=40): + count("news") + assert since.tzinfo is not None + self.news_since = since + self.news_max_items = max_items + return sample_news() + + def fetch_events(session, fred_api_key): + count("events") + return [EconEvent(name="CPI", actual="2.9%", kind="released")] + + def fetch_earnings(session, tickers): + count("earnings") + self.earnings_tickers = tickers + return [EarningsItem(symbol="NVDA", name="엔비디아", report_date=session, timing="amc")] + + def summarize_payload(payload, provider): + count("llm") + self.summarize_payloads.append(payload) + return sample_report(), "MiniMax-M3" + + def render_markdown(payload, report, model_used): + count("render") + tag = "degraded" if report is None else report.headline + return f"# 브리핑 {payload.krx_date}\n{tag}\nmodel={model_used}\n" + + monkeypatch.setattr(pipeline, "MarketClock", lambda offset, **_kw: self.clock) + monkeypatch.setattr(pipeline, "Store", lambda path: self.store) + monkeypatch.setattr(pipeline, "fetch_quotes", fetch_quotes) + monkeypatch.setattr(pipeline, "fetch_news", fetch_news) + monkeypatch.setattr(pipeline, "fetch_events", fetch_events) + monkeypatch.setattr(pipeline, "fetch_earnings", fetch_earnings) + monkeypatch.setattr(pipeline, "build_provider", lambda settings: object()) + monkeypatch.setattr(pipeline, "summarize_payload", summarize_payload) + monkeypatch.setattr(pipeline, "render_markdown", render_markdown) + monkeypatch.setattr(pipeline, "build_deliverers", lambda settings: self.deliverers) + monkeypatch.setattr(pipeline, "_now", lambda: NOW) + self.monkeypatch = monkeypatch + + +@pytest.fixture +def env(monkeypatch, tmp_path): + return Env(monkeypatch, tmp_path) + + +# ------------------------------------------------------------------------------ build_payload +def test_build_payload_assembles_everything(env): + payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store) + + assert payload.session_date == SESSION + assert payload.us_close_time == datetime(2026, 8, 25, 16, 0, tzinfo=ET) + assert payload.krx_date == date(2026, 8, 26) + assert payload.krx_open_today is True + assert payload.generated_at == NOW + assert [q.symbol for q in payload.quotes] == ["^GSPC", "NVDA"] + assert len(payload.news) == 1 and len(payload.events) == 1 and len(payload.earnings) == 1 + assert payload.history_notes == ["S&P 500 3일 연속 상승"] + assert payload.data_gaps == [] + # news window derived from close time and settings + assert env.news_since == payload.us_close_time - __import__("datetime").timedelta(hours=18) + assert env.news_max_items == 40 + # earnings tickers = semis + megacap + korea_linked + assert {"NVDA", "AAPL", "KB", "CPNG"} <= set(env.earnings_tickers) + assert "EWY" not in env.earnings_tickers # ETF: never reports earnings + assert "^GSPC" not in env.earnings_tickers + # persisted + assert env.store.quotes[SESSION] == payload.quotes + assert {name for (_, name) in env.store.raw} == {"prices", "news", "events", "earnings"} + assert '"^GSPC"' in env.store.raw[(SESSION, "prices")] + + +def test_build_payload_records_gap_and_continues_when_collector_raises(env): + def boom(since, max_items=40): + raise RuntimeError("feed down") + + env.monkeypatch.setattr(pipeline, "fetch_news", boom) + payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store) + + assert payload.news == [] + assert payload.data_gaps == ["news: RuntimeError: feed down"] + assert len(payload.quotes) == 2 # other collectors still ran + assert env.calls["events"] == 1 and env.calls["earnings"] == 1 + + +def test_build_payload_flags_empty_quotes(env): + env.monkeypatch.setattr(pipeline, "fetch_quotes", lambda session, tickers=None: []) + payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store) + + assert payload.quotes == [] + assert "prices: no quotes" in payload.data_gaps + assert SESSION not in env.store.quotes # nothing to save + + +def test_build_payload_does_not_duplicate_prices_gap_on_failure(env): + def boom(session, tickers=None): + raise ConnectionError("yahoo down") + + env.monkeypatch.setattr(pipeline, "fetch_quotes", boom) + payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store) + prices_gaps = [g for g in payload.data_gaps if g.startswith("prices:")] + assert len(prices_gaps) == 1 and "ConnectionError" in prices_gaps[0] + + +def test_build_payload_history_failure_is_a_gap(env): + env.store.fail_history = True + payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store) + assert payload.history_notes == [] + assert any(g.startswith("history:") for g in payload.data_gaps) + + +# -------------------------------------------------------------------------------- run_briefing +def test_run_briefing_happy_path_saves_then_delivers(env): + result = pipeline.run_briefing(SESSION, env.settings) + + assert result.error is None and result.degraded is False + assert result.report is not None and result.model_used == "MiniMax-M3" + assert result.markdown.startswith("# 브리핑 2026-08-26") + assert env.store.has_report(date(2026, 8, 26)) + assert env.store.saved_results == [result] + assert env.deliverer.delivered == [result] + assert env.store.sent == [(1, "file", "file-receipt-1")] + assert env.deliverer.errors == [] + assert env.store.closed is True + assert env.summarize_payloads[0].session_date == SESSION + + +def test_run_briefing_llm_failure_degrades_and_notifies(env): + def failing(payload, provider): + raise LLMError("MiniMax-M3: 500; MiniMax-M2.7: timeout") + + env.monkeypatch.setattr(pipeline, "summarize_payload", failing) + result = pipeline.run_briefing(SESSION, env.settings) + + assert result.degraded is True and result.report is None and result.model_used is None + assert "MiniMax-M3: 500" in (result.error or "") + assert "degraded" in result.markdown + assert env.store.has_report(date(2026, 8, 26)) # degraded briefing is still recorded + assert env.deliverer.delivered == [result] # ...and delivered + assert env.deliverer.errors == [(pipeline.DEGRADED_SUBJECT, result.error)] + + +def test_run_briefing_unexpected_provider_error_also_degrades(env): + def bad_provider(settings): + raise ValueError("MINIMAX_API_KEY missing") + + env.monkeypatch.setattr(pipeline, "build_provider", bad_provider) + result = pipeline.run_briefing(SESSION, env.settings) + assert result.degraded is True and "MINIMAX_API_KEY missing" in (result.error or "") + assert env.deliverer.delivered == [result] + + +def test_run_briefing_skips_when_already_sent(env): + env.store.reports[(date(2026, 8, 26), "briefing")] = 99 + result = pipeline.run_briefing(SESSION, env.settings) + + assert result.error == pipeline.SKIP_ALREADY_SENT + assert result.report is None and result.markdown == "" + assert result.payload.krx_date == date(2026, 8, 26) + assert env.calls == {} # nothing collected + assert env.deliverer.delivered == [] + assert env.store.saved_results == [] + assert env.store.closed is True + + +def test_run_briefing_force_reruns_even_if_sent(env): + env.store.reports[(date(2026, 8, 26), "briefing")] = 99 + result = pipeline.run_briefing(SESSION, env.settings, force=True) + + assert result.error is None + assert env.calls["prices"] == 1 and env.calls["llm"] == 1 + assert env.deliverer.delivered == [result] + assert env.store.saved_results == [result] + + +def test_run_briefing_dry_run_prints_and_does_not_save_or_deliver(env, capsys): + result = pipeline.run_briefing(SESSION, env.settings, dry_run=True) + + out = capsys.readouterr().out + assert result.markdown in out + assert env.store.saved_results == [] and env.store.sent == [] + assert env.deliverer.delivered == [] + assert result.report is not None + + +def test_run_briefing_dry_run_ignores_already_sent(env, capsys): + env.store.reports[(date(2026, 8, 26), "briefing")] = 99 + result = pipeline.run_briefing(SESSION, env.settings, dry_run=True) + assert result.error is None + assert env.calls["prices"] == 1 + assert "브리핑" in capsys.readouterr().out + + +def test_run_briefing_no_llm_is_degraded_without_error_notification(env): + result = pipeline.run_briefing(SESSION, env.settings, no_llm=True) + + assert result.degraded is True and result.report is None and result.error is None + assert "llm" not in env.calls + assert env.deliverer.delivered == [result] + assert env.deliverer.errors == [] # deliberate no-LLM run is not an operator error + + +def test_run_briefing_krx_closed_skip_when_configured(monkeypatch, tmp_path): + env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true") + env.clock.krx_open = False + result = pipeline.run_briefing(SESSION, env.settings) + + assert result.error == pipeline.SKIP_KRX_CLOSED + assert result.payload.krx_open_today is False + assert env.calls == {} and env.deliverer.delivered == [] + + +def test_run_briefing_krx_closed_still_runs_by_default(env): + env.clock.krx_open = False + result = pipeline.run_briefing(SESSION, env.settings) + assert result.error is None + assert result.payload.krx_open_today is False + assert env.deliverer.delivered == [result] + + +def test_run_briefing_uses_latest_completed_session_when_none(env): + env.clock.latest = date(2026, 8, 24) + result = pipeline.run_briefing(None, env.settings) + assert result.payload.session_date == date(2026, 8, 24) + assert result.payload.krx_date == date(2026, 8, 25) + assert env.clock.calls[0][0] == "latest_completed_session" + + +def test_run_briefing_rejects_non_session_date(env): + with pytest.raises(ValueError, match="not a US trading session"): + pipeline.run_briefing(date(2026, 8, 23), env.settings) # Sunday + assert env.store.closed is True + + +def test_run_briefing_one_failing_deliverer_does_not_block_others(env): + broken = FakeDeliverer("telegram", fail=True) + env.deliverers.append(broken) + result = pipeline.run_briefing(SESSION, env.settings) + + assert env.deliverer.delivered == [result] + assert broken.delivered == [] + assert env.store.sent == [(1, "file", "file-receipt-1")] + assert env.store.has_report(date(2026, 8, 26)) + + +def test_run_briefing_saves_report_before_delivery(env): + order: list[str] = [] + original_save = env.store.save_report + + def save_report(result, kind="briefing"): + order.append("save") + return original_save(result, kind) + + class OrderedDeliverer(FakeDeliverer): + def deliver(self, result): + order.append("deliver") + return super().deliver(result) + + env.store.save_report = save_report + env.deliverers[:] = [OrderedDeliverer("file")] + pipeline.run_briefing(SESSION, env.settings) + assert order == ["save", "deliver"] + + +def test_earnings_tickers_come_from_expected_groups(): + tickers = pipeline.earnings_tickers() + assert tickers[0] == "NVDA" + assert "TSLA" in tickers and "KB" in tickers + assert "^VIX" not in tickers and "ES=F" not in tickers + assert "EWY" not in tickers + assert len(tickers) == len(set(tickers)) + + +def test_run_briefing_warns_when_session_not_closed_yet(env, caplog): + env.monkeypatch.setattr( + pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST) + ) # before close + with caplog.at_level("WARNING", logger="us2kr.pipeline"): + result = pipeline.run_briefing(SESSION, env.settings, dry_run=True) + assert result.error is None + assert "has not closed yet" in caplog.text + + +# ------------------------------------------------------------------------ not-yet-closed session +def test_run_briefing_refuses_to_persist_session_not_closed_yet(env): + env.monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST)) + with pytest.raises(ValueError, match="has not closed yet"): + pipeline.run_briefing(SESSION, env.settings, no_llm=True) + assert env.calls == {} # nothing collected, nothing saved + assert not env.store.has_report(date(2026, 8, 26)) and env.store.quotes == {} + assert env.deliverer.delivered == [] + assert env.store.closed is True + + +def test_run_briefing_force_does_not_bypass_not_closed_guard(env): + env.monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 25, 15, 0, tzinfo=KST)) + with pytest.raises(ValueError, match="has not closed yet"): + pipeline.run_briefing(SESSION, env.settings, force=True, no_llm=True) + assert not env.store.has_report(date(2026, 8, 26)) + + +# ------------------------------------------------------------------------ delivery failures +def test_run_briefing_delivery_failure_is_reported_and_alerted(env): + broken = FakeDeliverer("telegram", fail=True) + env.deliverers.append(broken) + result = pipeline.run_briefing(SESSION, env.settings) + + assert pipeline.delivery_failed(result) + assert result.error.startswith("delivery failed: telegram: RuntimeError: telegram is down") + assert result.degraded is False # the briefing itself was fine + assert env.deliverer.delivered == [result] and broken.delivered == [] + assert env.store.sent == [(1, "file", "file-receipt-1")] + assert env.store.has_report(date(2026, 8, 26)) # stays recorded; resend via --force + # alert through every channel: the file channel writes errors.log, the broken one is tried too + assert [s for s, _ in env.deliverer.errors] == [pipeline.DELIVERY_FAILED_SUBJECT] + detail = env.deliverer.errors[0][1] + assert "telegram" in detail and "2026-08-26" in detail and "run-once --force --date 2026-08-25" in detail + assert broken.errors == env.deliverer.errors + + +def test_run_briefing_degraded_plus_delivery_failure_sends_both_alerts(env): + def failing(payload, provider): + raise LLMError("MiniMax-M3: 500") + + env.monkeypatch.setattr(pipeline, "summarize_payload", failing) + env.deliverers.append(FakeDeliverer("telegram", fail=True)) + result = pipeline.run_briefing(SESSION, env.settings) + + assert result.degraded and pipeline.delivery_failed(result) + assert result.error.startswith("MiniMax-M3: 500 | delivery failed: telegram") + subjects = [s for s, _ in env.deliverer.errors] + assert subjects == [pipeline.DEGRADED_SUBJECT, pipeline.DELIVERY_FAILED_SUBJECT] + assert env.deliverer.errors[0][1] == "MiniMax-M3: 500" # degraded alert carries only the LLM error + + +def test_delivery_failed_helper(env): + result = pipeline.run_briefing(SESSION, env.settings) + assert not pipeline.delivery_failed(result) + result.error = "LLM failed" + assert not pipeline.delivery_failed(result) + + +# ------------------------------------------------------------------------ skip_when_krx_closed semantics +def _real_clock(env): + clock = MarketClock(env.settings.run_offset_minutes, anchor=date(2026, 8, 25)) + env.monkeypatch.setattr(pipeline, "MarketClock", lambda offset, **_kw: clock) + return clock + + +def test_friday_session_is_delivered_even_when_skip_flag_set(monkeypatch, tmp_path): + env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true") + _real_clock(env) + monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 8, 22, 6, 15, tzinfo=KST)) + result = pipeline.run_briefing(date(2026, 8, 21), env.settings) # Fri -> Sat krx_date + assert result.error is None + assert result.payload.krx_date == date(2026, 8, 22) and result.payload.krx_open_today is False + assert env.calls["prices"] == 1 and env.deliverer.delivered == [result] + + +def test_early_close_before_krx_holiday_weekend_is_delivered_when_flag_set(monkeypatch, tmp_path): + # Thu 12-24 (13:00 ET early close) -> Fri 12-25 (KRX closed); next US = next KRX = Mon 12-28 + env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true") + _real_clock(env) + monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 12, 25, 4, 15, tzinfo=KST)) + result = pipeline.run_briefing(date(2026, 12, 24), env.settings) + assert result.error is None and result.payload.krx_open_today is False + assert env.deliverer.delivered == [result] + + +def test_chuseok_superseded_session_is_skipped_when_flag_set(monkeypatch, tmp_path): + # Wed 09-23 -> Thu 09-24 (추석): US trades again Thu 09-24 before KRX reopens Mon 09-28 + env = Env(monkeypatch, tmp_path, US2KR_SKIP_WHEN_KRX_CLOSED="true") + _real_clock(env) + monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 9, 24, 6, 15, tzinfo=KST)) + result = pipeline.run_briefing(date(2026, 9, 23), env.settings) + assert result.error == pipeline.SKIP_KRX_CLOSED + assert env.calls == {} and env.deliverer.delivered == [] + # ...but the last US session before KRX reopens (Fri 09-25 -> Sat 09-26) is delivered + monkeypatch.setattr(pipeline, "_now", lambda: datetime(2026, 9, 26, 6, 15, tzinfo=KST)) + result = pipeline.run_briefing(date(2026, 9, 25), env.settings) + assert result.error is None and env.deliverer.delivered == [result] + + +def test_superseded_helper_with_real_clock(): + clock = MarketClock(75, anchor=date(2026, 8, 25)) + cases = { + date(2026, 8, 21): False, # Fri -> Sat: Monday KRX needs it + date(2026, 12, 24): False, # early close -> Fri 12-25 holiday; next US = next KRX = Mon 12-28 + date(2026, 9, 23): True, # Chuseok run: US 09-24 closes before KRX reopens 09-28 + date(2026, 10, 8): True, # 한글날 Fri 10-09: US 10-09 closes before KRX Mon 10-12 + } + for session, expected in cases.items(): + krx_date = clock.krx_date_for(session) + assert clock.is_krx_session(krx_date) is False + assert pipeline._superseded_before_krx_open(session, krx_date, clock) is expected, session diff --git a/tests/test_prices.py b/tests/test_prices.py new file mode 100644 index 0000000..2334b54 --- /dev/null +++ b/tests/test_prices.py @@ -0,0 +1,463 @@ +"""Tests for us2kr.collectors.prices (no network: yfinance.download / yfinance.Ticker are monkeypatched).""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import ClassVar +from zoneinfo import ZoneInfo + +import numpy as np +import pandas as pd +import pytest +import yfinance as yf + +from us2kr.collectors import prices +from us2kr.collectors.prices import PriceFetchError, fetch_quotes + +SESSION = date(2026, 8, 24) # Monday +ET = ZoneInfo("America/New_York") +FIELDS = ["Open", "High", "Low", "Close", "Adj Close", "Volume"] + + +# ------------------------------------------------------------------------------------------------ +# fixtures / helpers +# ------------------------------------------------------------------------------------------------ + + +def _dates(n_before: int, n_after: int = 0) -> pd.DatetimeIndex: + """Calendar-day index (weekends included, like the union index yf.download produces).""" + start = pd.Timestamp(SESSION) - pd.Timedelta(days=n_before) + return pd.date_range(start, pd.Timestamp(SESSION) + pd.Timedelta(days=n_after), freq="D") + + +def _ticker_block(idx: pd.DatetimeIndex, closes: dict[date, float], volume: float | None = 1_000.0): + """Build a 6-column block for one ticker; rows not in ``closes`` are NaN (weekend / no trade).""" + block = pd.DataFrame(index=idx, columns=FIELDS, dtype="float64") + for d, c in closes.items(): + ts = pd.Timestamp(d) + block.loc[ts, "Open"] = c - 1.0 + block.loc[ts, "High"] = c + 2.0 + block.loc[ts, "Low"] = c - 3.0 + block.loc[ts, "Close"] = c + block.loc[ts, "Adj Close"] = c + block.loc[ts, "Volume"] = np.nan if volume is None else volume + return block + + +def _weekday_closes(last_close: float, n: int, step: float = 1.0, end: date = SESSION) -> dict[date, float]: + """n weekday closes ending on ``end`` (inclusive), increasing by ``step`` per day: ... , last-step, last.""" + out: dict[date, float] = {} + d = end + k = 0 + while len(out) < n: + if d.weekday() < 5: + out[d] = last_close - step * k + k += 1 + d -= timedelta(days=1) + return out + + +def _multi(blocks: dict[str, pd.DataFrame]) -> pd.DataFrame: + """Assemble the (Ticker, Price) MultiIndex frame exactly like yf.download(group_by='ticker').""" + df = pd.concat(blocks.values(), axis=1, keys=blocks.keys(), names=["Ticker", "Price"]) + df.index.name = "Date" + return df + + +class FakeTicker: + """Stand-in for yfinance.Ticker; ``history`` returns pre-registered frames or raises.""" + + frames: ClassVar[dict[str, pd.DataFrame]] = {} + errors: ClassVar[set[str]] = set() + calls: ClassVar[list[tuple[str, dict]]] = [] + + def __init__(self, ticker: str, session=None): + self.ticker = ticker + + def history(self, **kwargs): + FakeTicker.calls.append((self.ticker, kwargs)) + if self.ticker in FakeTicker.errors: + raise RuntimeError(f"boom {self.ticker}") + return FakeTicker.frames.get(self.ticker, pd.DataFrame()) + + +@pytest.fixture(autouse=True) +def _reset_fake_ticker(monkeypatch): + FakeTicker.frames = {} + FakeTicker.errors = set() + FakeTicker.calls = [] + monkeypatch.setattr(yf, "Ticker", FakeTicker) + yield + + +@pytest.fixture +def batch_frame() -> pd.DataFrame: + idx = _dates(n_before=20, n_after=1) # includes the day AFTER the session (24h instruments) + gspc = _weekday_closes(6500.0, 12, step=10.0) # 6500 on SESSION, 6490 on Friday, ... + tnx = _weekday_closes(4.25, 12, step=-0.05) # 4.25 on SESSION, 4.30 on Friday (yield fell 5bp) + aapl = _weekday_closes(200.0, 12, step=2.0) + krw = _weekday_closes(1380.0, 12, step=5.0) + krw[SESSION + timedelta(days=1)] = 1399.0 # bar AFTER the session must be ignored + blocks = { + "^GSPC": _ticker_block(idx, gspc, volume=4.0e9), + "^TNX": _ticker_block(idx, tnx, volume=0.0), + "AAPL": _ticker_block(idx, aapl, volume=50e6), + "KRW=X": _ticker_block(idx, krw, volume=0.0), + "MISSING": _ticker_block(idx, {}), # all-NaN column, as yfinance emits for a bad symbol + } + # make AAPL's session volume differ from the prior average (prior 10 bars = 50e6, session = 100e6) + blocks["AAPL"].loc[pd.Timestamp(SESSION), "Volume"] = 100e6 + return _multi(blocks) + + +@pytest.fixture +def patch_download(monkeypatch): + def _install(frame_or_exc, *sequence): + calls: list[dict] = [] + items = [frame_or_exc, *sequence] + + def fake_download(tickers, **kwargs): + calls.append({"tickers": list(tickers), **kwargs}) + item = items[min(len(calls) - 1, len(items) - 1)] + if isinstance(item, Exception): + raise item + return item + + monkeypatch.setattr(yf, "download", fake_download) + return calls + + return _install + + +def _by_symbol(quotes): + return {q.symbol: q for q in quotes} + + +# ------------------------------------------------------------------------------------------------ +# tests: per-group computations +# ------------------------------------------------------------------------------------------------ + + +def test_batch_download_called_with_date_window(batch_frame, patch_download): + calls = patch_download(batch_frame) + fetch_quotes(SESSION, ["^GSPC", "AAPL"]) + assert len(calls) == 1 + call = calls[0] + assert call["tickers"] == ["^GSPC", "AAPL"] + assert call["group_by"] == "ticker" + assert call["auto_adjust"] is False + assert call["interval"] == "1d" + assert call["progress"] is False + assert call["start"] == (SESSION - timedelta(days=prices.HISTORY_DAYS)).isoformat() + assert call["end"] == (SESSION + timedelta(days=1)).isoformat() # end is exclusive + + +def test_index_quote_fields(batch_frame, patch_download): + patch_download(batch_frame) + q = _by_symbol(fetch_quotes(SESSION, ["^GSPC"]))["^GSPC"] + assert q.group == "indices" + assert q.name == "S&P 500" + assert q.close == pytest.approx(6500.0) + assert q.prev_close == pytest.approx(6490.0) + assert q.change_abs == pytest.approx(10.0) + assert q.change_pct == pytest.approx((6500.0 / 6490.0 - 1) * 100) + assert q.day_high == pytest.approx(6502.0) + assert q.day_low == pytest.approx(6497.0) + assert q.volume == pytest.approx(4.0e9) + assert q.avg_volume_10d == pytest.approx(4.0e9) + assert q.as_of == datetime(2026, 8, 24, tzinfo=ET) + assert q.source == "yfinance" + assert q.after_hours_price is None # indices never get after-hours lookups + + +def test_rates_change_abs_in_yield_points(batch_frame, patch_download): + patch_download(batch_frame) + q = _by_symbol(fetch_quotes(SESSION, ["^TNX"]))["^TNX"] + assert q.group == "rates" + assert q.close == pytest.approx(4.25) + assert q.prev_close == pytest.approx(4.30) + assert q.change_abs == pytest.approx(-0.05) + assert q.change_pct == pytest.approx((4.25 / 4.30 - 1) * 100) + assert q.volume is None # zero volume is reported as None + assert q.avg_volume_10d is None + + +def test_fx_close_selected_by_date_not_last_row(batch_frame, patch_download): + """KRW=X has a bar dated after the session; close must be the session-date bar, prev the one before.""" + patch_download(batch_frame) + q = _by_symbol(fetch_quotes(SESSION, ["KRW=X"]))["KRW=X"] + assert q.group == "fx" + assert q.close == pytest.approx(1380.0) + assert q.prev_close == pytest.approx(1375.0) + assert q.change_abs == pytest.approx(5.0) + + +def test_equity_volume_ratio_inputs(batch_frame, patch_download): + patch_download(batch_frame) + q = _by_symbol(fetch_quotes(SESSION, ["AAPL"]))["AAPL"] + assert q.group == "megacap" + assert q.name == "애플" + assert q.volume == pytest.approx(100e6) + assert q.avg_volume_10d == pytest.approx(50e6) # prior 10 sessions only, session day excluded + + +def test_missing_ticker_skipped_and_order_preserved(batch_frame, patch_download, caplog): + patch_download(batch_frame) + with caplog.at_level("WARNING", logger="us2kr.collectors.prices"): + quotes = fetch_quotes(SESSION, ["^GSPC", "MISSING", "AAPL", "NOTINFRAME"]) + assert [q.symbol for q in quotes] == ["^GSPC", "AAPL"] + assert "MISSING" in caplog.text + assert "NOTINFRAME" in caplog.text + + +def test_unknown_ticker_gets_other_group(patch_download): + idx = _dates(10) + frame = _multi({"ZZZ": _ticker_block(idx, _weekday_closes(10.0, 5))}) + patch_download(frame) + q = fetch_quotes(SESSION, ["ZZZ"])[0] + assert q.group == "other" + assert q.name == "ZZZ" + + +def test_default_tickers_is_symbol_universe(batch_frame, patch_download): + calls = patch_download(batch_frame) + fetch_quotes(SESSION) + from us2kr import symbols + + assert calls[0]["tickers"] == symbols.all_tickers() + + +def test_empty_ticker_list_returns_empty_without_download(patch_download): + calls = patch_download(pd.DataFrame()) + assert fetch_quotes(SESSION, []) == [] + assert calls == [] + + +# ------------------------------------------------------------------------------------------------ +# tests: session_date edge cases +# ------------------------------------------------------------------------------------------------ + + +def test_session_row_missing_uses_last_prior_bar(patch_download, caplog): + """If the session-date bar is absent (not yet published), fall back to the last bar before it.""" + idx = _dates(10) + friday = SESSION - timedelta(days=3) + closes = _weekday_closes(100.0, 5, step=1.0, end=friday) # 100 on Fri, 99 on Thu + frame = _multi({"XLK": _ticker_block(idx, closes)}) + patch_download(frame) + with caplog.at_level("INFO", logger="us2kr.collectors.prices"): + q = fetch_quotes(SESSION, ["XLK"])[0] + assert q.close == pytest.approx(100.0) + assert q.prev_close == pytest.approx(99.0) + assert q.as_of.date() == friday + assert "no 2026-08-24 bar" in caplog.text + + +def test_only_one_bar_gives_no_change(patch_download): + idx = _dates(3) + frame = _multi({"XLF": _ticker_block(idx, {SESSION: 50.0})}) + patch_download(frame) + q = fetch_quotes(SESSION, ["XLF"])[0] + assert q.close == pytest.approx(50.0) + assert q.prev_close is None + assert q.change_pct is None + assert q.change_abs is None + assert q.avg_volume_10d is None + + +def test_nan_close_rows_are_ignored_for_prev_close(patch_download): + idx = _dates(10) + closes = _weekday_closes(100.0, 5, step=1.0) + block = _ticker_block(idx, closes) + friday = SESSION - timedelta(days=3) + block.loc[pd.Timestamp(friday), "Close"] = np.nan # broken row must be skipped, not used as prev + frame = _multi({"XLE": block}) + patch_download(frame) + q = fetch_quotes(SESSION, ["XLE"])[0] + assert q.close == pytest.approx(100.0) + assert q.prev_close == pytest.approx(98.0) # Thursday + + +def test_nan_volume_becomes_none(patch_download): + idx = _dates(10) + frame = _multi({"XLV": _ticker_block(idx, _weekday_closes(100.0, 5), volume=None)}) + patch_download(frame) + q = fetch_quotes(SESSION, ["XLV"])[0] + assert q.volume is None + assert q.avg_volume_10d is None + + +def test_all_bars_after_session_date_skips_ticker(patch_download): + idx = _dates(0, n_after=2) + frame = _multi({"BTC-USD": _ticker_block(idx, {SESSION + timedelta(days=1): 1.0})}) + patch_download(frame) + assert fetch_quotes(SESSION, ["BTC-USD"]) == [] + + +# ------------------------------------------------------------------------------------------------ +# tests: frame layouts +# ------------------------------------------------------------------------------------------------ + + +def test_single_level_frame_for_single_ticker(patch_download): + idx = _dates(10) + frame = _ticker_block(idx, _weekday_closes(30.0, 5, step=0.5)) + patch_download(frame) + q = fetch_quotes(SESSION, ["^SOX"])[0] + assert q.close == pytest.approx(30.0) + assert q.prev_close == pytest.approx(29.5) + + +def test_group_by_column_layout_is_handled(batch_frame, patch_download): + swapped = batch_frame.swaplevel(0, 1, axis=1).sort_index(axis=1) + patch_download(swapped) + q = _by_symbol(fetch_quotes(SESSION, ["^GSPC"]))["^GSPC"] + assert q.close == pytest.approx(6500.0) + + +def test_tz_aware_index_is_normalised(patch_download): + idx = pd.date_range(pd.Timestamp(SESSION) - pd.Timedelta(days=6), pd.Timestamp(SESSION), freq="D") + block = _ticker_block(idx, _weekday_closes(10.0, 4)) + block.index = block.index.tz_localize(ET) + patch_download(_multi({"SMH": block})) + q = fetch_quotes(SESSION, ["SMH"])[0] + assert q.close == pytest.approx(10.0) + assert q.prev_close == pytest.approx(9.0) + + +@pytest.mark.parametrize("tz", ["Europe/London", "UTC", "America/New_York", "America/Chicago"]) +def test_tz_aware_daily_index_keeps_exchange_local_date(tz): + """London/UTC-stamped daily bars (KRW=X, BTC-USD via Ticker.history) must not shift a day earlier.""" + idx = pd.DatetimeIndex(pd.to_datetime(["2026-08-20", "2026-08-21", "2026-08-24", "2026-08-25"])) + frame = pd.DataFrame( + {"Close": [1380.0, 1385.0, 1390.0, 1400.0], "Volume": [0.0] * 4}, index=idx.tz_localize(tz) + ) + cleaned = prices._clean_single(frame) + assert cleaned is not None and cleaned.index.tz is None + dates = [d.date().isoformat() for d in cleaned.index] + assert dates == ["2026-08-20", "2026-08-21", "2026-08-24", "2026-08-25"] + q = prices._quote_from_history("KRW=X", cleaned, SESSION) + assert q.close == pytest.approx(1390.0) + assert q.prev_close == pytest.approx(1385.0) + assert q.as_of == datetime(2026, 8, 24, tzinfo=ET) + + +# ------------------------------------------------------------------------------------------------ +# tests: failure policy +# ------------------------------------------------------------------------------------------------ + + +def test_batch_retried_once_then_succeeds(batch_frame, patch_download): + calls = patch_download(RuntimeError("yahoo down"), batch_frame) + quotes = fetch_quotes(SESSION, ["^GSPC"]) + assert len(calls) == 2 + assert quotes[0].close == pytest.approx(6500.0) + + +def test_batch_failure_falls_back_to_indices_only(patch_download): + patch_download(RuntimeError("down"), RuntimeError("still down")) + idx = _dates(10) + FakeTicker.frames["^GSPC"] = _ticker_block(idx, _weekday_closes(6000.0, 5, step=10.0)) + FakeTicker.frames["AAPL"] = _ticker_block(idx, _weekday_closes(200.0, 5)) + quotes = fetch_quotes(SESSION, ["^GSPC", "AAPL", "^VIX"]) + # only indices are attempted in fallback; AAPL is not requested, ^VIX yields nothing + assert [q.symbol for q in quotes] == ["^GSPC"] + assert quotes[0].close == pytest.approx(6000.0) + history_calls = [c for c in FakeTicker.calls if c[1].get("interval") == "1d"] + assert sorted(t for t, _ in history_calls) == ["^GSPC", "^VIX"] + + +def test_empty_batch_frames_count_as_failure(patch_download): + patch_download(pd.DataFrame(), None) + FakeTicker.frames["^DJI"] = _ticker_block(_dates(10), _weekday_closes(40000.0, 5)) + quotes = fetch_quotes(SESSION, ["^DJI"]) + assert quotes[0].close == pytest.approx(40000.0) + + +def test_total_failure_raises_price_fetch_error(patch_download): + patch_download(RuntimeError("down")) + FakeTicker.errors = {"^GSPC"} + with pytest.raises(PriceFetchError) as exc_info: + fetch_quotes(SESSION, ["^GSPC", "AAPL"]) + assert "down" in str(exc_info.value) + assert "^GSPC" in str(exc_info.value) + + +def test_run_safely_records_gap_on_total_failure(patch_download): + from us2kr.collectors.base import run_safely + + patch_download(RuntimeError("down")) + gaps: list[str] = [] + out = run_safely("prices", lambda: fetch_quotes(SESSION, ["AAPL"]), [], gaps) + assert out == [] + assert gaps and gaps[0].startswith("prices: PriceFetchError") + + +# ------------------------------------------------------------------------------------------------ +# tests: after-hours (best effort) +# ------------------------------------------------------------------------------------------------ + + +def _minute_frame(closes_by_time: dict[str, float], day: date = SESSION) -> pd.DataFrame: + stamps = [pd.Timestamp(f"{day.isoformat()} {t}", tz=ET) for t in closes_by_time] + return pd.DataFrame( + {"Open": list(closes_by_time.values()), "Close": list(closes_by_time.values()), "Volume": 0}, + index=pd.DatetimeIndex(stamps, name="Datetime"), + ) + + +def test_after_hours_price_from_post_close_minute_bars(batch_frame, patch_download): + patch_download(batch_frame) + FakeTicker.frames["AAPL"] = _minute_frame( + {"15:58": 199.0, "15:59": 200.0, "16:00": 201.0, "19:59": 204.0} + ) + q = _by_symbol(fetch_quotes(SESSION, ["AAPL", "^GSPC", "KRW=X"])) + assert q["AAPL"].after_hours_price == pytest.approx(204.0) + assert q["AAPL"].after_hours_change_pct == pytest.approx(2.0) + assert q["^GSPC"].after_hours_price is None + assert q["KRW=X"].after_hours_price is None + # only the equity was queried, with prepost 1m bars for the session day + assert [t for t, _ in FakeTicker.calls] == ["AAPL"] + kwargs = FakeTicker.calls[0][1] + assert kwargs["interval"] == "1m" and kwargs["prepost"] is True + assert kwargs["start"] == SESSION.isoformat() + + +def test_after_hours_skipped_when_only_regular_bars(batch_frame, patch_download): + patch_download(batch_frame) + FakeTicker.frames["AAPL"] = _minute_frame({"15:58": 199.0, "15:59": 200.0}) + q = _by_symbol(fetch_quotes(SESSION, ["AAPL"]))["AAPL"] + assert q.after_hours_price is None + assert q.after_hours_change_pct is None + + +def test_after_hours_ignores_bars_from_other_days(batch_frame, patch_download): + patch_download(batch_frame) + FakeTicker.frames["AAPL"] = _minute_frame({"17:00": 150.0}, day=SESSION - timedelta(days=3)) + q = _by_symbol(fetch_quotes(SESSION, ["AAPL"]))["AAPL"] + assert q.after_hours_price is None + + +def test_after_hours_failure_is_isolated(batch_frame, patch_download): + patch_download(batch_frame) + FakeTicker.errors = {"AAPL"} + q = _by_symbol(fetch_quotes(SESSION, ["AAPL", "^GSPC"])) + assert q["AAPL"].close == pytest.approx(200.0) + assert q["AAPL"].after_hours_price is None + assert q["^GSPC"].close == pytest.approx(6500.0) + + +def test_after_hours_respects_time_budget(batch_frame, patch_download, monkeypatch): + patch_download(batch_frame) + FakeTicker.frames["AAPL"] = _minute_frame({"16:30": 210.0}) + monkeypatch.setattr(prices, "AFTER_HOURS_BUDGET_SECONDS", 0.0) + q = _by_symbol(fetch_quotes(SESSION, ["AAPL"]))["AAPL"] + assert q.after_hours_price is None + assert FakeTicker.calls == [] + + +def test_is_equity_like_classification(): + assert prices._is_equity_like("AAPL") + assert prices._is_equity_like("EWY") + for t in ("^GSPC", "ES=F", "KRW=X", "DX-Y.NYB", "BTC-USD", "^TNX"): + assert not prices._is_equity_like(t), t diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..ebff299 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,365 @@ +"""Tests for us2kr.render (Markdown + Telegram HTML chunking). No network.""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from zoneinfo import ZoneInfo + +import pytest + +from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report +from us2kr.render import render_markdown, render_telegram_chunks +from us2kr.symbols import GROUP_TITLES_KO, display_name, group_of + +ET = ZoneInfo("America/New_York") +KST = ZoneInfo("Asia/Seoul") +UTC = ZoneInfo("UTC") + + +def _q(symbol: str, close: float, prev: float, **kw) -> Quote: + return Quote( + symbol=symbol, + name=kw.pop("name", display_name(symbol)), + group=kw.pop("group", group_of(symbol) or "other"), + close=close, + prev_close=prev, + change_pct=(close / prev - 1) * 100, + change_abs=close - prev, + **kw, + ) + + +def _payload(**overrides) -> MarketPayload: + base: dict = { + "session_date": date(2026, 8, 25), + "us_close_time": datetime(2026, 8, 25, 16, 0, tzinfo=ET), + "generated_at": datetime(2026, 8, 26, 6, 15, tzinfo=KST), + "krx_date": date(2026, 8, 26), + "krx_open_today": True, + "quotes": [ + _q("^GSPC", 6432.1, 6398.9), + _q("^VIX", 15.2, 16.0), + _q("^TNX", 4.256, 4.221), + _q("KRW=X", 1395.2, 1390.0), + _q("NVDA", 182.5, 180.0, after_hours_price=185.0, after_hours_change_pct=1.37), + _q("TSM", 240.0, 238.0), + ], + "news": [ + NewsItem( + title="Fed's Powell [signals] cuts & more", + summary="", + link="https://example.com/a?x=1&y=2", + source="CNBC", + published=datetime(2026, 8, 25, 21, 30, tzinfo=UTC), # 06:30 KST + ), + NewsItem( + title="Chips ", + link="https://example.com/b", + source="MarketWatch", + published=datetime(2026, 8, 25, 20, 0, tzinfo=UTC), + ), + ], + "events": [ + EconEvent( + name="CPI (YoY)", + time=datetime(2026, 8, 25, 8, 30, tzinfo=ET), + actual="2.9%", + forecast="3.0%", + previous="3.1%", + importance="high", + kind="released", + ), + EconEvent(name="FOMC 의사록", time=datetime(2026, 8, 26, 14, 0, tzinfo=ET), kind="upcoming"), + ], + "earnings": [ + EarningsItem( + symbol="NVDA", + name="엔비디아", + report_date=date(2026, 8, 25), + timing="amc", + eps_actual=1.05, + eps_estimate=1.01, + revenue_actual=46_700_000_000, + revenue_estimate=46_000_000_000, + ) + ], + "history_notes": ["S&P 500 3일 연속 상승"], + "data_gaps": ["events: TimeoutError: ..."], + } + base.update(overrides) + return MarketPayload(**base) + + +def _report() -> Report: + return Report( + headline="반도체 주도 반등, S&P 500은 9999% 상승", + market_overview="지수는 상승 마감했다.\n반도체가 주도했다.", + key_drivers=["엔비디아 실적 기대", "금리 하락"], + rates_fx_commodities="10년물 금리 하락, 달러 약세.", + korea_implications=["SOX 강세 → 국내 반도체 우호적", "원화 강세"], + watchlist_today=["삼성전자 실적"], + risk_flags=["FOMC 의사록"], + data_gaps=["events"], + ) + + +# ------------------------------------------------------------------ render_markdown + + +def test_markdown_header_and_degraded_mode(): + md = render_markdown(_payload(krx_open_today=False), None, None) + assert md.startswith("# 🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-08-26 (미 2026-08-25 세션)\n") + assert "미국 마감: 2026-08-26 05:00 KST · 생성: 2026-08-26 06:15 KST" in md + assert "⚠️ 오늘 한국 휴장" in md + assert "🧩 축소 모드(LLM 요약 없음)" in md + assert "## 총평" not in md and "## 시장 개요" not in md + assert "## 📊 주요 지수" in md # tables still present + assert md.rstrip().endswith("요약 모델: 없음") + + +def test_markdown_with_report_sections_and_numbers_from_payload(): + md = render_markdown(_payload(), _report(), "MiniMax-M3") + assert "🧩 축소 모드" not in md + assert "⚠️ 오늘 한국 휴장" not in md + assert "## 총평\n**반도체 주도 반등, S&P 500은 9999% 상승**" in md + assert "## 시장 개요\n지수는 상승 마감했다. 반도체가 주도했다." in md # newlines collapsed + assert "## 주요 요인\n- 엔비디아 실적 기대\n- 금리 하락" in md + assert "## 금리·환율·원자재\n10년물 금리 하락, 달러 약세." in md + assert "## 한국 장 시사점\n- SOX 강세 → 국내 반도체 우호적" in md + assert "## 오늘 볼 것\n- 삼성전자 실적" in md + assert "## 리스크\n- FOMC 의사록" in md + # numeric table values come from the payload + assert "| S&P 500 | 6,432.10 | +0.52% |" in md + assert "| VIX | 15.20 | -5.00% |" in md + assert md.rstrip().endswith("요약 모델: MiniMax-M3") + + +def test_markdown_group_order_and_sections(): + md = render_markdown(_payload(), None, None) + positions = [md.index(f"## 📊 {GROUP_TITLES_KO[g]}") for g in ("indices", "rates", "fx", "semis")] + assert positions == sorted(positions) + assert "## 📊 야간 선물" not in md # group with no quotes is omitted + for title in ("## 히스토리 노트", "## 주요 뉴스", "## 경제지표·실적", "## 수집 누락"): + assert title in md + assert md.index("## 📊 빅테크") < md.index("## 히스토리 노트") if "## 📊 빅테크" in md else True + assert "- S&P 500 3일 연속 상승" in md + assert "- events: TimeoutError: ..." in md + + +def test_markdown_rates_bp_formatting(): + md = render_markdown(_payload(), None, None) + assert "| 미 10년물 | 4.256 | +3.5bp |" in md + + +def test_markdown_after_hours_column_only_when_present(): + md = render_markdown(_payload(), None, None) + semis = md.split("## 📊 반도체")[1].split("## ")[0] + assert "| 종목 | 종가 | 변동 | 애프터 |" in semis + assert "| 엔비디아 | 182.50 | +1.39% | 185.00 (+1.37%) |" in semis + assert "| TSMC | 240.00 | +0.84% | — |" in semis + indices = md.split("## 📊 주요 지수")[1].split("## ")[0] + assert "애프터" not in indices + + +def test_markdown_missing_values_and_pipe_escape(): + q = Quote(symbol="XYZ", name="A|B Corp", group="indices") + md = render_markdown(_payload(quotes=[q]), None, None) + assert "| A\\|B Corp | — | — |" in md + + +def test_markdown_news_events_earnings(): + md = render_markdown(_payload(), None, None) + assert "- [Fed's Powell (signals) cuts & more](https://example.com/a?x=1&y=2) — CNBC, 06:30 KST" in md + assert "- [Chips ](https://example.com/b) — MarketWatch, 05:00 KST" in md + assert "★ CPI (YoY): 실제 2.9% / 예상 3.0% / 이전 3.1%" in md + assert "[발표 08/25 21:30 KST]" in md # 08:30 ET -> 21:30 KST + assert "- [예정 08/27 03:00 KST] FOMC 의사록" in md + assert ( + "- [실적·장마감후] 엔비디아(NVDA) 2026-08-25: EPS 1.05 (예상 1.01) · 매출 46.70B (예상 46.00B)" in md + ) + + +def test_markdown_news_capped_at_12(): + news = [ + NewsItem( + title=f"t{i}", link=f"https://e.com/{i}", source="S", published=datetime(2026, 8, 25, tzinfo=UTC) + ) + for i in range(20) + ] + md = render_markdown(_payload(news=news), None, None) + assert len(re.findall(r"^- \[t\d+\]", md, flags=re.MULTILINE)) == 12 + + +def test_markdown_optional_sections_omitted_when_empty(): + md = render_markdown( + _payload(news=[], events=[], earnings=[], history_notes=[], data_gaps=[]), None, None + ) + for title in ("## 히스토리 노트", "## 주요 뉴스", "## 경제지표·실적", "## 수집 누락"): + assert title not in md + + +# ------------------------------------------------------------------ render_telegram_chunks + + +def _assert_balanced(chunk: str) -> None: + for tag in ("pre", "b", "a", "i"): + opens = len(re.findall(rf"<{tag}(?:\s[^>]*)?>", chunk)) + closes = chunk.count(f"") + assert opens == closes, (tag, chunk) + # no stray unescaped angle brackets / ampersands outside tags + stripped = re.sub(r"<(/?)(pre|b|a|i)(\s[^>]*)?>", "", chunk) + assert "<" not in stripped and ">" not in stripped, stripped + assert re.search(r"&(?!amp;|lt;|gt;|quot;)", stripped) is None, stripped + + +def test_telegram_conversion_basics(): + md = render_markdown(_payload(), _report(), "MiniMax-M3") + chunks = render_telegram_chunks(md, limit=4000) + assert chunks + joined = "\n".join(chunks) + for c in chunks: + assert len(c) <= 4000 + _assert_balanced(c) + # headers and bold + assert "🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-08-26 (미 2026-08-25 세션)" in joined + assert "총평\n반도체 주도 반등, S&P 500은 9999% 상승" in joined + # links converted + escaped + assert 'Fed\'s Powell (signals) cuts & more' in joined + assert 'Chips <rally>' in joined + # bullets + assert "• 엔비디아 실적 기대" in joined + # tables -> pre with aligned monospace and no markdown pipes + assert "
" in joined and "
" in joined + pre_blocks = re.findall(r"
\n(.*?)\n
", joined, flags=re.DOTALL) + assert pre_blocks + assert any("6,432.10" in b and "+0.52%" in b for b in pre_blocks) + assert not any("|" in b for b in pre_blocks) + # horizontal rule dropped, no raw markdown syntax left + assert "\n---" not in joined + assert "**" not in joined and "](" not in joined and "\n## " not in joined + + +def test_telegram_pre_alignment_and_escape(): + md = "## T\n| 종목 | 종가 |\n|---|---|\n| A&B | 1.00 |\n| LongName | 12,345.00 |\n" + chunks = render_telegram_chunks(md) + assert len(chunks) == 1 + body = chunks[0] + assert body.startswith("T\n
\n")
+    lines = body.split("\n")[2:-1]
+    assert lines[0].startswith("종목")
+    assert "A&B" in lines[1] and "LongName <x>" in lines[2]
+    # numeric column right-aligned: values end at same position (after unescaping)
+    unesc = [ln.replace("&", "&").replace("<", "<").replace(">", ">") for ln in lines]
+    ends = {len(ln) for ln in unesc[1:]}
+    assert len(ends) == 1
+
+
+def test_telegram_greedy_packing_by_section():
+    md = "\n".join(
+        [
+            "# Title",
+            "meta",
+            "",
+            "## A",
+            "a" * 100,
+            "",
+            "## B",
+            "b" * 100,
+            "",
+            "## C",
+            "c" * 100,
+            "",
+        ]
+    )
+    chunks = render_telegram_chunks(md, limit=260)
+    assert all(len(c) <= 260 for c in chunks)
+    assert len(chunks) == 2
+    assert chunks[0].startswith("Title\nmeta\n\nA\n")
+    assert "B" in chunks[0] and "C" in chunks[1]
+    assert chunks[1].startswith("C")
+
+
+def test_telegram_split_inside_pre_keeps_balance():
+    rows = "\n".join(f"| Name{i:02d} | {i * 1000:,.2f} | +{i}.00% |" for i in range(60))
+    md = f"## Big\n| 종목 | 종가 | 변동 |\n|---|---|---|\n{rows}\n"
+    limit = 300
+    chunks = render_telegram_chunks(md, limit=limit)
+    assert len(chunks) > 1
+    for c in chunks:
+        assert len(c) <= limit
+        _assert_balanced(c)
+        assert c.count("
") == 1  # every chunk of the table reopens the block
+    assert chunks[0].startswith("Big\n
\n")
+    assert chunks[1].startswith("
\n")
+    # nothing lost
+    all_text = "\n".join(chunks)
+    for i in range(60):
+        assert f"Name{i:02d}" in all_text
+
+
+def test_telegram_split_mixed_text_and_pre_in_one_section():
+    lines = ["## S"] + [f"- item {i} " + "x" * 40 for i in range(30)]
+    lines += ["| a | b |", "|---|---|"] + [f"| r{i} | {i} |" for i in range(30)]
+    lines += [f"tail {i} " + "y" * 30 for i in range(10)]
+    chunks = render_telegram_chunks("\n".join(lines), limit=400)
+    assert len(chunks) > 3
+    for c in chunks:
+        assert len(c) <= 400
+        _assert_balanced(c)
+        assert not c.startswith("\n") and not c.endswith("\n")
+    joined = "\n".join(chunks)
+    assert joined.count("• item") == 30 and joined.count("tail ") == 10
+    assert all(f"r{i} " in joined or f"r{i}\n" in joined or f"r{i}  " in joined for i in range(30))
+
+
+def test_telegram_limit_accounts_for_escaping():
+    md = "## E\n" + "&" * 100  # -> 500 chars once escaped
+    chunks = render_telegram_chunks(md, limit=120)
+    assert all(len(c) <= 120 for c in chunks)
+    assert "".join(chunks).count("&") == 100
+
+
+def test_telegram_empty_input():
+    assert render_telegram_chunks("") == []
+    assert render_telegram_chunks("\n\n---\n") == []
+
+
+@pytest.mark.parametrize("limit", [200, 1000, 4000])
+def test_telegram_full_briefing_roundtrip(limit: int):
+    md = render_markdown(_payload(), _report(), "MiniMax-M3")
+    chunks = render_telegram_chunks(md, limit=limit)
+    for c in chunks:
+        assert 0 < len(c) <= limit
+        _assert_balanced(c)
+    joined = "\n".join(chunks)
+    assert "요약 모델: MiniMax-M3" in joined
+    assert "미 10년물" in joined and "+3.5bp" in joined
+
+
+def test_news_link_with_parentheses_and_space_survives_telegram_conversion():
+    from us2kr.render import _inline, _link_url, _news_lines
+
+    published = datetime(2026, 8, 25, tzinfo=UTC)
+    items = [
+        NewsItem(title="p", link="https://ex.com/a_(b)?x=1&y=2", source="S", published=published),
+        NewsItem(title="s", link=" https://ex.com/a b/c ", source="S", published=published),
+        NewsItem(
+            title="e",
+            link="https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag",
+            source="S",
+            published=published,
+        ),
+    ]
+    html = [_inline(line) for line in _news_lines(items)]
+    assert html[0].startswith('- p — S, ')
+    assert ")" not in html[0].split("", 1)[1]
+    assert 's' in html[1]
+    assert "[s](" not in html[1]
+    # already-encoded URLs are left untouched (no double encoding)
+    assert _link_url("https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag") == "https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag"
+    assert 'e' in html[2]
+    # end-to-end through the chunker: exactly one anchor per item, no stray raw markdown
+    md = render_markdown(_payload(news=items), None, None)
+    joined = "\n".join(render_telegram_chunks(md))
+    assert joined.count(" Quote:
+    return Quote(
+        symbol=symbol,
+        name=display_name(symbol),
+        group=group_of(symbol) or "other",
+        close=close,
+        prev_close=prev,
+        change_pct=(close / prev - 1) * 100,
+        change_abs=close - prev,
+        **kw,
+    )
+
+
+def _payload(krx: date = date(2026, 8, 26), session: date = date(2026, 8, 25)) -> MarketPayload:
+    return MarketPayload(
+        session_date=session,
+        us_close_time=datetime(2026, 8, 25, 16, 0, tzinfo=ET),
+        generated_at=datetime(2026, 8, 26, 6, 15, tzinfo=KST),
+        krx_date=krx,
+        krx_open_today=True,
+        quotes=[_q("^GSPC", 6400.0, 6350.0)],
+    )
+
+
+def _report() -> Report:
+    return Report(
+        headline="반도체 강세",
+        market_overview="개요",
+        key_drivers=["a"],
+        rates_fx_commodities="금리",
+        korea_implications=["b"],
+        watchlist_today=["c"],
+        risk_flags=["d"],
+    )
+
+
+@pytest.fixture
+def store(tmp_path: Path):
+    s = Store(tmp_path / "nested" / "dir" / "test.sqlite3")
+    yield s
+    s.close()
+
+
+def _seed(store: Store, symbol: str, closes: list[float], end: date) -> None:
+    """Seed consecutive prior sessions ending the day before ``end`` with the given closes."""
+    days = []
+    d = end
+    while len(days) < len(closes):
+        d -= timedelta(days=1)
+        if d.weekday() < 5:
+            days.append(d)
+    days.reverse()
+    prev = closes[0]
+    for d, c in zip(days, closes, strict=True):
+        store.save_quotes(d, [_q(symbol, c, prev)])
+        prev = c
+
+
+# ------------------------------------------------------------------ schema / quotes
+
+
+def test_creates_parent_dirs_and_wal(tmp_path: Path):
+    path = tmp_path / "a" / "b" / "x.sqlite3"
+    s = Store(path)
+    try:
+        assert path.exists()
+        mode = s._conn.execute("PRAGMA journal_mode").fetchone()[0]
+        assert mode.lower() == "wal"
+        tables = {r[0] for r in s._conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
+        assert {"quotes", "reports", "deliveries", "raw"} <= tables
+    finally:
+        s.close()
+
+
+def test_save_quotes_upserts(store: Store):
+    d = date(2026, 8, 25)
+    store.save_quotes(d, [_q("^GSPC", 6400.0, 6350.0), _q("NVDA", 180.0, 175.0)])
+    store.save_quotes(d, [_q("^GSPC", 6410.0, 6350.0)])  # same key -> update, not duplicate
+    rows = store.load_quotes(d)
+    assert len(rows) == 2
+    by_sym = {r["symbol"]: r for r in rows}
+    assert by_sym["^GSPC"]["close"] == pytest.approx(6410.0)
+    assert by_sym["NVDA"]["close"] == pytest.approx(180.0)
+
+
+def test_save_quotes_handles_none_values(store: Store):
+    q = Quote(symbol="^VIX", name="VIX", group="indices")
+    store.save_quotes(date(2026, 8, 25), [q])
+    row = store.load_quotes(date(2026, 8, 25))[0]
+    assert row["close"] is None and row["change_pct"] is None
+
+
+# ------------------------------------------------------------------ reports / deliveries / raw
+
+
+def test_has_report_and_save_report_upsert(store: Store):
+    payload = _payload()
+    assert store.has_report(payload.krx_date) is False
+    r1 = BriefingResult(payload=payload, report=_report(), markdown="# md1", model_used="MiniMax-M3")
+    id1 = store.save_report(r1)
+    assert store.has_report(payload.krx_date) is True
+    assert store.has_report(payload.krx_date, kind="premarket") is False
+
+    r2 = BriefingResult(payload=payload, report=None, markdown="# md2", degraded=True)
+    id2 = store.save_report(r2)
+    assert id2 == id1  # upsert on (krx_date, kind)
+    row = store._conn.execute("SELECT * FROM reports WHERE id = ?", (id1,)).fetchone()
+    assert row["markdown"] == "# md2"
+    assert row["degraded"] == 1
+    assert row["model_used"] is None
+    assert row["session_date"] == "2026-08-25"
+    assert json.loads(row["result_json"])["degraded"] is True
+    assert store._conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0] == 1
+
+    id3 = store.save_report(r1, kind="premarket")
+    assert id3 != id1
+    assert store.has_report(payload.krx_date, kind="premarket") is True
+
+
+def test_mark_sent(store: Store):
+    rid = store.save_report(BriefingResult(payload=_payload(), report=None, markdown="x"))
+    store.mark_sent(rid, "telegram", "msg:123")
+    store.mark_sent(rid, "file", "reports/2026-08-26.md")
+    rows = store.deliveries_for(rid)
+    assert [(r["channel"], r["receipt"]) for r in rows] == [
+        ("telegram", "msg:123"),
+        ("file", "reports/2026-08-26.md"),
+    ]
+    assert all(r["sent_at"] for r in rows)
+
+
+def test_save_raw_upsert(store: Store):
+    d = date(2026, 8, 25)
+    store.save_raw(d, "quotes", '{"a": 1}')
+    store.save_raw(d, "quotes", '{"a": 2}')
+    store.save_raw(d, "news", "[]")
+    assert store.load_raw(d, "quotes") == '{"a": 2}'
+    assert store.load_raw(d, "news") == "[]"
+    assert store.load_raw(d, "missing") is None
+    assert store._conn.execute("SELECT COUNT(*) FROM raw").fetchone()[0] == 2
+
+
+def test_close_then_use_raises(tmp_path: Path):
+    s = Store(tmp_path / "x.sqlite3")
+    s.close()
+    with pytest.raises(sqlite3.ProgrammingError):
+        s.has_report(date(2026, 1, 1))
+
+
+# ------------------------------------------------------------------ history notes
+
+
+def test_history_notes_empty_without_history(store: Store):
+    quotes = [_q("^GSPC", 6400.0, 6350.0), _q("^VIX", 21.0, 19.0)]
+    assert store.history_notes(date(2026, 8, 25), quotes) == []
+
+
+def test_history_notes_down_streak(store: Store):
+    today = date(2026, 8, 25)
+    # three prior sessions all down, today down too -> 4-day streak
+    _seed(store, "^GSPC", [6500.0, 6480.0, 6460.0, 6440.0], today)
+    notes = store.history_notes(today, [_q("^GSPC", 6420.0, 6440.0)])
+    assert any("S&P 500 4일 연속 하락" in n for n in notes), notes
+    # a reversal breaks the streak
+    notes_up = store.history_notes(today, [_q("^GSPC", 6450.0, 6440.0)])
+    assert not any("연속" in n for n in notes_up), notes_up
+
+
+def test_history_notes_streak_requires_three(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "NVDA", [100.0, 101.0], today)  # only one up day stored
+    notes = store.history_notes(today, [_q("NVDA", 102.0, 101.0)])
+    assert not any("연속" in n for n in notes), notes
+
+
+def test_history_notes_ignores_today_in_db(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "^IXIC", [100.0, 101.0, 102.0, 103.0], today)
+    q = _q("^IXIC", 104.0, 103.0)
+    store.save_quotes(today, [q])  # pipeline may save before asking for notes
+    notes = store.history_notes(today, [q])
+    assert any("나스닥 종합 4일 연속 상승" in n for n in notes), notes
+
+
+def test_history_notes_big_move(store: Store):
+    today = date(2026, 8, 25)
+    # alternating +-0.3% moves for 8 sessions, then a -2.5% day
+    closes = [1000.0]
+    for i in range(8):
+        closes.append(closes[-1] * (1.003 if i % 2 == 0 else 0.997))
+    _seed(store, "^SOX", closes, today)
+    prev = closes[-1]
+    notes = store.history_notes(today, [_q("^SOX", prev * 0.975, prev)])
+    big = [n for n in notes if "평소보다 큰 변동" in n]
+    assert len(big) == 1 and big[0].startswith("필라델피아 반도체(SOX) 오늘 -2.50%"), notes
+    # a normal-sized move produces no flag
+    notes2 = store.history_notes(today, [_q("^SOX", prev * 1.002, prev)])
+    assert not any("평소보다" in n for n in notes2), notes2
+
+
+def test_history_notes_big_move_needs_enough_sessions(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "^SOX", [1000.0, 1003.0, 1000.0], today)  # only 3 prior sessions
+    notes = store.history_notes(today, [_q("^SOX", 950.0, 1000.0)])
+    assert not any("평소보다" in n for n in notes), notes
+
+
+def test_history_notes_rates_in_bp(store: Store):
+    today = date(2026, 8, 25)
+    closes = [4.20, 4.21, 4.20, 4.22, 4.21, 4.22]
+    _seed(store, "^TNX", closes, today)
+    notes = store.history_notes(today, [_q("^TNX", 4.32, 4.22)])  # +10bp vs ~1bp avg
+    big = [n for n in notes if "평소보다 큰 변동" in n]
+    assert len(big) == 1 and "+10.0bp" in big[0], notes
+
+
+def test_history_notes_vix_crossing(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "^VIX", [17.0, 18.5], today)
+    up = store.history_notes(today, [_q("^VIX", 21.2, 18.5)])
+    assert any("VIX 20 상향 돌파" in n and "18.5 → 21.2" in n for n in up), up
+    down = store.history_notes(today, [_q("^VIX", 19.0, 18.5)])
+    assert not any("돌파" in n or "이탈" in n for n in down), down
+
+    _seed(store, "^VIX", [28.0, 31.0], today)  # overwrite prior sessions
+    dn = store.history_notes(today, [_q("^VIX", 29.5, 31.0)])
+    assert any("VIX 30 하향 이탈" in n for n in dn), dn
+
+
+def test_history_notes_krw_crossing(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "KRW=X", [1390.0, 1395.2], today)
+    up = store.history_notes(today, [_q("KRW=X", 1402.1, 1395.2)])
+    assert any("달러/원 1,400원 상향 돌파" in n for n in up), up
+    flat = store.history_notes(today, [_q("KRW=X", 1398.0, 1395.2)])
+    assert not any("돌파" in n or "이탈" in n for n in flat), flat
+    _seed(store, "KRW=X", [1410.0, 1402.0], today)
+    dn = store.history_notes(today, [_q("KRW=X", 1396.0, 1402.0)])
+    assert any("달러/원 1,400원 하향 이탈" in n for n in dn), dn
+
+
+def test_history_notes_only_key_symbols(store: Store):
+    today = date(2026, 8, 25)
+    _seed(store, "AAPL", [100.0, 101.0, 102.0, 103.0], today)
+    notes = store.history_notes(today, [_q("AAPL", 104.0, 103.0)])
+    assert notes == []
+
+
+# ------------------------------------------------------------------ streak contiguity
+
+
+def test_history_notes_streak_breaks_on_missing_sessions(store: Store):
+    # 08-17, 08-18, 08-21 stored (all up); 08-19/08-20 missing (daemon down). Today 08-24 up.
+    store.save_quotes(date(2026, 8, 17), [_q("^GSPC", 6400.0, 6380.0)])
+    store.save_quotes(date(2026, 8, 18), [_q("^GSPC", 6420.0, 6400.0)])
+    store.save_quotes(date(2026, 8, 21), [_q("^GSPC", 6370.0, 6350.0)])  # prev_close != 6420: hole
+    notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6390.0, 6370.0)])
+    assert not any("연속" in n for n in notes), notes
+
+
+def test_history_notes_streak_breaks_on_calendar_gap_even_if_prices_chain(store: Store):
+    # Same closes chain, but a 6-day hole (08-18 -> 08-24) cannot be two consecutive sessions.
+    store.save_quotes(date(2026, 8, 14), [_q("^GSPC", 6400.0, 6380.0)])
+    store.save_quotes(date(2026, 8, 17), [_q("^GSPC", 6420.0, 6400.0)])
+    store.save_quotes(date(2026, 8, 18), [_q("^GSPC", 6440.0, 6420.0)])
+    notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6460.0, 6440.0)])
+    assert not any("연속" in n for n in notes), notes
+
+
+def test_history_notes_streak_spans_weekend(store: Store):
+    store.save_quotes(date(2026, 8, 19), [_q("^GSPC", 6330.0, 6300.0)])
+    store.save_quotes(date(2026, 8, 20), [_q("^GSPC", 6350.0, 6330.0)])
+    store.save_quotes(date(2026, 8, 21), [_q("^GSPC", 6370.0, 6350.0)])  # Fri
+    notes = store.history_notes(date(2026, 8, 24), [_q("^GSPC", 6390.0, 6370.0)])  # Mon
+    assert any("S&P 500 4일 연속 상승" in n for n in notes), notes
+
+
+def test_history_notes_streak_spans_holiday_weekend(store: Store):
+    # Wed 9/2, Thu 9/3, Fri 9/4 up; Mon 9/7 Labor Day; today Tue 9/8 up -> genuine 4-day streak
+    store.save_quotes(date(2026, 9, 2), [_q("^GSPC", 100.5, 100.0)])
+    store.save_quotes(date(2026, 9, 3), [_q("^GSPC", 101.0, 100.5)])
+    store.save_quotes(date(2026, 9, 4), [_q("^GSPC", 101.5, 101.0)])
+    notes = store.history_notes(date(2026, 9, 8), [_q("^GSPC", 102.0, 101.5)])
+    assert any("S&P 500 4일 연속 상승" in n for n in notes), notes
+
+
+def test_contiguous_tail_tolerates_small_revisions_and_missing_prices():
+    rows = [
+        _Row("2026-08-18", 100.0, 99.0, 1.0, 1.0),
+        _Row("2026-08-19", 101.0, 100.15, 0.85, 0.85),  # prev_close revised by 0.15% -> still chained
+        _Row("2026-08-20", None, None, None, None),  # no prices: calendar gap alone decides
+        _Row("2026-08-21", 103.0, 102.0, 1.0, 1.0),
+    ]
+    assert _contiguous_tail(rows, date(2026, 8, 24), 103.0) == rows
+    assert _contiguous_tail(rows, date(2026, 8, 24), 110.0) == []  # today does not follow 08-21
+    assert _contiguous_tail(rows, date(2026, 8, 28), 103.0) == []  # 7-day gap
+    assert _contiguous_tail([], date(2026, 8, 24), 103.0) == []
+
+
+# ------------------------------------------------------------------ level crossings
+
+
+def test_history_notes_level_cross_uses_quote_prev_close(store: Store):
+    today = date(2026, 8, 25)
+    # stored snapshot 18.0 but the collector's preceding bar is 21.0 -> a real cross below 20
+    _seed(store, "^VIX", [17.0, 18.0], today)
+    notes = store.history_notes(today, [_q("^VIX", 19.5, 21.0)])
+    assert any("VIX 20 하향 이탈" in n and "21.0 → 19.5" in n for n in notes), notes
+    # stored snapshot 1398 but the preceding bar is already 1402 -> no 1,400 crossing must be reported
+    _seed(store, "KRW=X", [1390.0, 1398.0], today)
+    notes = store.history_notes(today, [_q("KRW=X", 1405.0, 1402.0)])
+    assert not any("돌파" in n or "이탈" in n for n in notes), notes
+    # without a collector prev_close the stored close is the fallback
+    q = Quote(symbol="^VIX", name="VIX", group="volatility", close=19.5)
+    _seed(store, "^VIX", [22.0, 21.0], today)
+    notes = store.history_notes(today, [q])
+    assert any("VIX 20 하향 이탈" in n and "21.0 → 19.5" in n for n in notes), notes
+
+
+def test_level_cross_note_never_shows_identical_prev_and_close():
+    notes = _level_cross_notes("달러/원", 1399.6, 1400.4, [1350.0, 1400.0, 1450.0], 0, "원")
+    assert notes == ["달러/원 1,400원 상향 돌파 (1,399.6 → 1,400.4)"]
+    notes = _level_cross_notes("VIX", 19.96, 20.04, [20.0, 30.0], 1)
+    assert notes == ["VIX 20 상향 돌파 (19.96 → 20.04)"]
+    # the normal case keeps the configured precision
+    assert _level_cross_notes("달러/원", 1395.2, 1402.1, [1400.0], 0, "원") == [
+        "달러/원 1,400원 상향 돌파 (1,395 → 1,402)"
+    ]
+
+
+def test_level_cross_requires_strict_cross():
+    assert _level_cross_notes("달러/원", 1399.99, 1400.0, [1400.0], 0, "원") == []  # lands on the level
+    assert _level_cross_notes("달러/원", 1400.0, 1399.99, [1400.0], 0, "원") == []  # leaves from the level
+    assert _level_cross_notes("VIX", 19.96, 20.0, [20.0, 30.0], 1) == []
+    assert _level_cross_notes("VIX", 20.0, 20.5, [20.0, 30.0], 1) == []
diff --git a/us2kr/.DS_Store b/us2kr/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..8cec9f11611825b5b09d0cacba05cb27bfff8a11
GIT binary patch
literal 6148
zcmeHKJx{|h5Iwh*sM@6?6AC|}1A;MBVMHKxV=1(aC@4{?@*%NBEJ#dUFs|GERb^8zo&t
zRJZ71{IvAE{#|tc7tzB(hDB+c$<)9zp$NYaqhpLmbmX#Kxhaavay@^$eH=i?qbof0R=UYq-nsicnXRKp)R`!PSQF)Z7
z!%6uDr56Q60at;td2Dd~-zdKSyGimS3Wx&#N&!^~+CdAiWNT~g<+#>*XuD`^9G4o@
kDd=oD)&nlZi)h*~=kWmOTTBhY1Cu`jS_bJvfj?E?9XB49RR910

literal 0
HcmV?d00001

diff --git a/us2kr/__init__.py b/us2kr/__init__.py
new file mode 100644
index 0000000..1d4e416
--- /dev/null
+++ b/us2kr/__init__.py
@@ -0,0 +1,3 @@
+"""US2KR - US market close -> Korea market open briefing bot."""
+
+__version__ = "0.1.0"
diff --git a/us2kr/cli.py b/us2kr/cli.py
new file mode 100644
index 0000000..49d7229
--- /dev/null
+++ b/us2kr/cli.py
@@ -0,0 +1,176 @@
+"""Command-line entry point: ``us2kr run-once | daemon | next-run | test-notify | show-config``."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import re
+import sys
+from datetime import date, datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+from us2kr import __version__
+from us2kr.config import ET, KST, Settings, get_settings
+from us2kr.daemon import run_daemon
+from us2kr.deliver import build_deliverers
+from us2kr.logging_setup import setup_logging
+from us2kr.market_time import MarketClock
+from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing
+
+log = logging.getLogger(__name__)
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+EXIT_OK = 0
+EXIT_ERROR = 1
+EXIT_SKIPPED = 2
+SECRET_FIELD_RE = re.compile(r"(api_key|_key|_token|secret|password)$")
+_PATH_FIELDS = ("data_dir", "reports_dir", "log_dir")
+
+
+def _resolve_paths(settings: Settings, root: Path = PROJECT_ROOT) -> Settings:
+    """Make relative path settings absolute against the project root (launchd starts with cwd=/)."""
+    update = {}
+    for name in _PATH_FIELDS:
+        value = Path(getattr(settings, name))
+        if not value.is_absolute():
+            update[name] = root / value
+    return settings.model_copy(update=update) if update else settings
+
+
+def _load_settings() -> Settings:
+    """Load settings; fall back to the project-root .env when none exists in the current directory."""
+    root_env = PROJECT_ROOT / ".env"
+    if not Path(".env").is_file() and root_env.is_file():
+        settings = Settings(_env_file=root_env)  # type: ignore[call-arg]
+    else:
+        settings = get_settings()
+    return _resolve_paths(settings, PROJECT_ROOT)
+
+
+def mask_secret(value: object) -> str:
+    if value is None or value == "":
+        return "(unset)"
+    text = str(value)
+    return f"{text[:4]}***" if len(text) > 8 else "***"
+
+
+def _parse_date(text: str) -> date:
+    try:
+        return date.fromisoformat(text)
+    except ValueError as exc:
+        raise argparse.ArgumentTypeError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(prog="us2kr", description="US market close -> Korea open briefing bot")
+    parser.add_argument("--version", action="version", version=f"us2kr {__version__}")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    once = sub.add_parser("run-once", help="build (and deliver) one briefing")
+    once.add_argument(
+        "--date",
+        type=_parse_date,
+        default=None,
+        help="US session date YYYY-MM-DD (must already be closed unless --dry-run)",
+    )
+    once.add_argument("--dry-run", action="store_true", help="print markdown, do not save or deliver")
+    once.add_argument("--force", action="store_true", help="re-run (resend) even if already sent")
+    once.add_argument("--no-llm", action="store_true", help="skip the LLM (tables only)")
+
+    sub.add_parser("daemon", help="run the resident scheduler")
+    sub.add_parser("next-run", help="show the next scheduled briefing")
+
+    notify = sub.add_parser("test-notify", help="send a test message through every delivery channel")
+    notify.add_argument("--message", default="US2KR test notification", help="text to send")
+
+    sub.add_parser("show-config", help="print effective settings (secrets masked)")
+    return parser
+
+
+# ---------------------------------------------------------------------------- commands
+def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int:
+    result = run_briefing(args.date, settings, dry_run=args.dry_run, force=args.force, no_llm=args.no_llm)
+    if result.error in (SKIP_ALREADY_SENT, SKIP_KRX_CLOSED):
+        print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
+        return EXIT_SKIPPED
+    if result.degraded:
+        log.warning("briefing produced in degraded mode: %s", result.error or "no LLM")
+    if delivery_failed(result):
+        print(f"error: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
+        return EXIT_ERROR
+    return EXIT_OK
+
+
+def cmd_daemon(_args: argparse.Namespace, settings: Settings) -> int:
+    run_daemon(settings)
+    return EXIT_OK
+
+
+def cmd_next_run(_args: argparse.Namespace, settings: Settings) -> int:
+    clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
+    now = datetime.now(ZoneInfo(KST))
+    session, run_at = clock.next_briefing(now)
+    close_et = clock.us_session_close(session)
+    krx_date = clock.krx_date_for(session)
+    krx_open = clock.is_krx_session(krx_date)
+    print(f"US session      : {session}")
+    print(f"US close (ET)   : {close_et.isoformat(timespec='minutes')} [{ET}]")
+    print(
+        f"briefing at KST : {run_at.isoformat(timespec='minutes')} (offset {settings.run_offset_minutes} min)"
+    )
+    print(f"KRX date        : {krx_date} ({'open' if krx_open else 'CLOSED'})")
+    return EXIT_OK
+
+
+def cmd_test_notify(args: argparse.Namespace, settings: Settings) -> int:
+    deliverers = build_deliverers(settings)
+    if not deliverers:
+        print("no delivery channels configured", file=sys.stderr)
+        return EXIT_ERROR
+    for deliverer in deliverers:
+        deliverer.notify_error("US2KR 테스트 알림", args.message)
+        print(f"sent test notification via {deliverer.name}")
+    return EXIT_OK
+
+
+def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
+    for name, value in settings.model_dump().items():
+        shown = mask_secret(value) if SECRET_FIELD_RE.search(name) else value
+        print(f"{name} = {shown}")
+    print(f"db_path = {settings.db_path}")
+    print(f"telegram_enabled = {settings.telegram_enabled}")
+    print(f"project_root = {PROJECT_ROOT}")
+    return EXIT_OK
+
+
+COMMANDS = {
+    "run-once": cmd_run_once,
+    "daemon": cmd_daemon,
+    "next-run": cmd_next_run,
+    "test-notify": cmd_test_notify,
+    "show-config": cmd_show_config,
+}
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = build_parser()
+    args = parser.parse_args(argv)
+    try:
+        settings = _load_settings()
+    except Exception as exc:  # noqa: BLE001 - bad .env must produce a readable message
+        print(f"error: could not load settings: {exc}", file=sys.stderr)
+        return EXIT_ERROR
+    setup_logging(settings)
+    try:
+        return COMMANDS[args.command](args, settings)
+    except KeyboardInterrupt:
+        return EXIT_ERROR
+    except Exception as exc:  # top-level guard: log and exit non-zero
+        log.exception("command %s failed", args.command)
+        print(f"error: {exc}", file=sys.stderr)
+        return EXIT_ERROR
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/us2kr/collectors/__init__.py b/us2kr/collectors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/us2kr/collectors/base.py b/us2kr/collectors/base.py
new file mode 100644
index 0000000..a206835
--- /dev/null
+++ b/us2kr/collectors/base.py
@@ -0,0 +1,29 @@
+"""Helpers shared by collectors: failure isolation and timing."""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Callable
+
+from us2kr.logging_setup import mask_secrets
+
+log = logging.getLogger(__name__)
+
+
+def run_safely[T](name: str, fn: Callable[[], T], default: T, gaps: list[str]) -> T:
+    """Run a collector; on any exception log it, append a gap note and return `default`.
+
+    The pipeline must never die because one data source is down.
+    """
+    t0 = time.monotonic()
+    try:
+        result = fn()
+        log.info("collector %s ok in %.1fs", name, time.monotonic() - t0)
+        return result
+    except Exception as exc:  # deliberate: isolate every failure
+        log.exception("collector %s failed", name)
+        # Gap notes end up in the briefing / Telegram / LLM prompt: never let a URL with a key through.
+        detail = mask_secrets(str(exc)).split(" for url: ", 1)[0][:120]
+        gaps.append(f"{name}: {type(exc).__name__}: {detail}")
+        return default
diff --git a/us2kr/collectors/events.py b/us2kr/collectors/events.py
new file mode 100644
index 0000000..d35daf0
--- /dev/null
+++ b/us2kr/collectors/events.py
@@ -0,0 +1,312 @@
+"""Economic calendar (FRED release dates) and earnings (yfinance) collectors.
+
+Both are best-effort: FRED is skipped entirely without an API key, and earnings lookups are
+bounded by a ticker cap and a wall-clock budget so a slow Yahoo never stalls the briefing.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import time
+from datetime import date, datetime, timedelta
+from datetime import time as dtime
+from zoneinfo import ZoneInfo
+
+import requests
+import yfinance as yf
+
+from us2kr.models import EarningsItem, EconEvent
+from us2kr.symbols import display_name
+
+log = logging.getLogger(__name__)
+
+ET = ZoneInfo("America/New_York")
+
+FRED_RELEASE_DATES_URL = "https://api.stlouisfed.org/fred/releases/dates"
+FRED_TIMEOUT_SECONDS = 10.0
+
+# Substring of the FRED release name (lower-cased) -> (Korean label, importance, typical release time ET).
+# Typical times are the customary BLS/BEA/Fed embargo times; None when the time varies.
+_KNOWN_RELEASES: list[tuple[str, str, str, dtime | None]] = [
+    ("consumer price index", "미국 소비자물가지수(CPI)", "high", dtime(8, 30)),
+    ("producer price index", "미국 생산자물가지수(PPI)", "high", dtime(8, 30)),
+    ("employment situation", "미국 고용보고서(비농업 고용·실업률)", "high", dtime(8, 30)),
+    ("gross domestic product", "미국 GDP", "high", dtime(8, 30)),
+    ("fomc", "FOMC", "high", dtime(14, 0)),
+    ("federal open market committee", "FOMC", "high", dtime(14, 0)),
+    ("advance monthly sales for retail", "미국 소매판매", "high", dtime(8, 30)),
+    ("personal income and outlays", "미국 개인소득·지출(PCE 물가)", "high", dtime(8, 30)),
+    ("unemployment insurance weekly claims", "주간 신규 실업수당 청구건수", "medium", dtime(8, 30)),
+    ("job openings and labor turnover", "구인·이직보고서(JOLTS)", "medium", dtime(10, 0)),
+    ("employment cost index", "고용비용지수(ECI)", "medium", dtime(8, 30)),
+    ("industrial production", "산업생산·설비가동률", "medium", dtime(9, 15)),
+    ("durable goods", "내구재 주문", "medium", dtime(8, 30)),
+    ("new residential construction", "주택착공·건축허가", "medium", dtime(8, 30)),
+    ("new residential sales", "신규주택판매", "low", dtime(10, 0)),
+    ("surveys of consumers", "미시간대 소비자심리", "medium", dtime(10, 0)),
+    ("beige book", "베이지북", "medium", dtime(14, 0)),
+    ("import and export price", "수출입물가", "low", dtime(8, 30)),
+    ("productivity and costs", "생산성·단위노동비용", "low", dtime(8, 30)),
+]
+
+EARNINGS_MAX_TICKERS = 15
+EARNINGS_TIME_BUDGET_SECONDS = 30.0
+
+
+# ---------------------------------------------------------------------------
+# Economic releases (FRED)
+# ---------------------------------------------------------------------------
+def _classify_release(name: str) -> tuple[str, str, dtime | None] | None:
+    lowered = name.lower()
+    for needle, ko, importance, at in _KNOWN_RELEASES:
+        if needle in lowered:
+            return ko, importance, at
+    return None
+
+
+def _without_url(exc: requests.RequestException) -> requests.RequestException:
+    """Same exception type as ``exc`` with a message that never contains the request URL / API key."""
+    response = getattr(exc, "response", None)
+    status = getattr(response, "status_code", None)
+    detail = f"HTTP {status}" if status is not None else "request failed"
+    message = f"FRED release calendar: {type(exc).__name__}: {detail}"
+    try:
+        return type(exc)(message, response=response)
+    except TypeError:  # exotic subclass signature (e.g. JSONDecodeError)
+        return requests.RequestException(message, response=response)
+
+
+def fetch_events(session_date: date, fred_api_key: str | None) -> list[EconEvent]:
+    """Well-known US releases dated `session_date` (released) or the next day (upcoming).
+
+    Returns [] when no FRED key is configured. Raises on HTTP/parse failure so the pipeline's
+    run_safely can record a data gap.
+    """
+    if not fred_api_key:
+        log.info("events: FRED_API_KEY not set, skipping economic calendar")
+        return []
+
+    end_date = session_date + timedelta(days=1)
+    params = {
+        "realtime_start": session_date.isoformat(),
+        "realtime_end": end_date.isoformat(),
+        "include_release_dates_with_no_data": "true",
+        "limit": "1000",
+        "api_key": fred_api_key,
+        "file_type": "json",
+    }
+    try:
+        resp = requests.get(FRED_RELEASE_DATES_URL, params=params, timeout=FRED_TIMEOUT_SECONDS)
+        resp.raise_for_status()
+    except requests.RequestException as exc:
+        # requests embeds the full URL (including api_key=...) in HTTPError/ConnectionError messages,
+        # which would otherwise end up in tracebacks and in the briefing's data_gaps line.
+        raise _without_url(exc) from None
+    data = resp.json()
+    rows = data.get("release_dates") or []
+
+    events: list[EconEvent] = []
+    seen: set[tuple[str, date]] = set()
+    for row in rows:
+        try:
+            rel_date = date.fromisoformat(str(row.get("date", ""))[:10])
+        except ValueError:
+            continue
+        if rel_date < session_date or rel_date > end_date:
+            continue
+        classified = _classify_release(str(row.get("release_name", "")))
+        if classified is None:
+            continue
+        ko, importance, at = classified
+        if (ko, rel_date) in seen:
+            continue
+        seen.add((ko, rel_date))
+        when = datetime.combine(rel_date, at, tzinfo=ET) if at else None
+        events.append(
+            EconEvent(
+                name=ko,
+                time=when,
+                country="US",
+                importance=importance,  # type: ignore[arg-type]
+                kind="released" if rel_date == session_date else "upcoming",
+            )
+        )
+
+    events.sort(key=lambda e: (e.kind != "released", e.time or datetime.max.replace(tzinfo=ET), e.name))
+    log.info(
+        "events: %d FRED release rows -> %d known events for %s/%s",
+        len(rows),
+        len(events),
+        session_date,
+        end_date,
+    )
+    return events
+
+
+# ---------------------------------------------------------------------------
+# Earnings (yfinance)
+# ---------------------------------------------------------------------------
+def _is_equity_ticker(ticker: str) -> bool:
+    return not (ticker.startswith("^") or "=" in ticker or ticker.endswith("-USD") or "." in ticker)
+
+
+def next_us_business_day(d: date) -> date:
+    """Next XNYS session after `d` (falls back to a weekday roll if the calendar is unavailable)."""
+    try:
+        import exchange_calendars as xcals
+
+        cal = xcals.get_calendar("XNYS", start=d - timedelta(days=7), end=d + timedelta(days=30))
+        return cal.date_to_session(d + timedelta(days=1), direction="next").date()
+    except Exception as exc:  # noqa: BLE001 - calendar is a nicety here, not a requirement
+        log.debug("XNYS calendar unavailable (%s); using weekday roll", exc)
+        nxt = d + timedelta(days=1)
+        while nxt.weekday() >= 5:
+            nxt += timedelta(days=1)
+        return nxt
+
+
+def _to_float(value) -> float | None:
+    try:
+        f = float(value)
+    except (TypeError, ValueError):
+        return None
+    return None if math.isnan(f) else f
+
+
+def _calendar_dates(cal: dict) -> list[date]:
+    """Dates from Ticker.calendar['Earnings Date'].
+
+    Caveat: yfinance derives these with datetime.fromtimestamp() in the *machine's* local zone, so on a
+    KST host an after-close (16:00 ET) report shows up one day late. Only used as a fallback.
+    """
+    raw = cal.get("Earnings Date") or []
+    if not isinstance(raw, (list, tuple)):
+        raw = [raw]
+    out: list[date] = []
+    for v in raw:
+        if isinstance(v, datetime):
+            out.append(v.date())
+        elif isinstance(v, date):
+            out.append(v)
+        elif hasattr(v, "date"):  # pandas.Timestamp
+            try:
+                out.append(v.date())
+            except Exception as exc:  # noqa: BLE001
+                log.debug("earnings: unparseable calendar date %r: %s", v, exc)
+    return out
+
+
+def _timing_from_hour(hour: int) -> str:
+    if hour < 12:
+        return "bmo"
+    if hour >= 16:
+        return "amc"
+    return "unknown"
+
+
+class _NoEarningsData(Exception):
+    """get_earnings_dates() returned nothing usable - caller should fall back to Ticker.calendar."""
+
+
+def _match_from_earnings_dates(tk, ticker: str, targets: set[date]) -> EarningsItem | None:
+    """Primary source: Ticker.get_earnings_dates() rows are tz-aware ET, so date and bmo/amc are exact.
+
+    Returns None when rows exist but none fall on a target date; raises _NoEarningsData when the
+    scrape yielded no rows at all.
+    """
+    df = tk.get_earnings_dates(limit=12)
+    if df is None or getattr(df, "empty", True):
+        raise _NoEarningsData(ticker)
+    for ts, row in df.iterrows():
+        try:
+            ts_et = ts.tz_convert(ET) if getattr(ts, "tzinfo", None) is not None else ts
+        except Exception:  # noqa: BLE001
+            ts_et = ts
+        report_date = ts_et.date()
+        if report_date not in targets:
+            continue
+        return EarningsItem(
+            symbol=ticker,
+            name=display_name(ticker),
+            report_date=report_date,
+            timing=_timing_from_hour(int(ts_et.hour)),  # type: ignore[arg-type]
+            eps_estimate=_to_float(row.get("EPS Estimate")),
+            eps_actual=_to_float(row.get("Reported EPS")),
+        )
+    return None
+
+
+def _match_from_calendar(tk, ticker: str, targets: set[date]) -> EarningsItem | None:
+    """Fallback source when the earnings-dates scrape is unavailable; timing stays unknown."""
+    cal = tk.calendar or {}
+    match = next((d for d in sorted(_calendar_dates(cal)) if d in targets), None)
+    if match is None:
+        return None
+    return EarningsItem(
+        symbol=ticker,
+        name=display_name(ticker),
+        report_date=match,
+        eps_estimate=_to_float(cal.get("Earnings Average")),
+        revenue_estimate=_to_float(cal.get("Revenue Average")),
+    )
+
+
+def _lookup_ticker(ticker: str, targets: set[date]) -> EarningsItem | None:
+    tk = yf.Ticker(ticker)
+    item: EarningsItem | None = None
+    try:
+        item = _match_from_earnings_dates(tk, ticker, targets)
+    except Exception as exc:  # noqa: BLE001 - scrape missing/broken: fall back to the calendar module
+        log.debug("earnings: %s earnings_dates unavailable (%s); trying calendar", ticker, type(exc).__name__)
+        return _match_from_calendar(tk, ticker, targets)
+    if item is None:
+        return None
+    # Enrich with consensus revenue (and EPS if the scrape lacked it) from the calendar module.
+    try:
+        cal = tk.calendar or {}
+        item.revenue_estimate = _to_float(cal.get("Revenue Average"))
+        if item.eps_estimate is None:
+            item.eps_estimate = _to_float(cal.get("Earnings Average"))
+    except Exception as exc:  # noqa: BLE001 - revenue consensus is optional
+        log.debug("earnings: %s calendar enrichment failed: %s", ticker, exc)
+    return item
+
+
+def fetch_earnings(session_date: date, tickers: list[str]) -> list[EarningsItem]:
+    """Tracked tickers reporting on `session_date` (after close) or the next US session (before open).
+
+    Best-effort: each ticker is isolated with try/except, at most EARNINGS_MAX_TICKERS are queried and
+    the whole scan stops once EARNINGS_TIME_BUDGET_SECONDS is spent.
+    """
+    targets = {session_date, next_us_business_day(session_date)}
+    equities = [t for t in tickers if _is_equity_ticker(t)]
+    candidates = equities[:EARNINGS_MAX_TICKERS]
+    if len(candidates) < len(equities):
+        log.info("earnings: capped tickers %d -> %d", len(equities), EARNINGS_MAX_TICKERS)
+
+    t0 = time.monotonic()
+    found: list[EarningsItem] = []
+    scanned = 0
+    for ticker in candidates:
+        if time.monotonic() - t0 > EARNINGS_TIME_BUDGET_SECONDS:
+            log.warning("earnings: time budget exhausted after %d/%d tickers", scanned, len(candidates))
+            break
+        scanned += 1
+        try:
+            item = _lookup_ticker(ticker, targets)
+        except Exception as exc:  # noqa: BLE001 - per-ticker isolation
+            log.warning("earnings: %s failed: %s: %s", ticker, type(exc).__name__, str(exc)[:120])
+            continue
+        if item is not None:
+            found.append(item)
+
+    found.sort(key=lambda e: (e.report_date, e.timing, e.symbol))
+    log.info(
+        "earnings: scanned %d tickers in %.1fs, %d reporting on %s",
+        scanned,
+        time.monotonic() - t0,
+        len(found),
+        sorted(targets),
+    )
+    return found
diff --git a/us2kr/collectors/news.py b/us2kr/collectors/news.py
new file mode 100644
index 0000000..fd621c3
--- /dev/null
+++ b/us2kr/collectors/news.py
@@ -0,0 +1,175 @@
+"""News collector: curated RSS feeds via requests + feedparser.
+
+Each feed is fetched independently so that one broken feed never spoils the others.
+Only if *every* feed fails does `fetch_news` raise.
+"""
+
+from __future__ import annotations
+
+import calendar
+import html
+import logging
+import re
+from datetime import UTC, datetime
+from html.parser import HTMLParser
+
+import feedparser
+import requests
+
+from us2kr.models import NewsItem
+
+log = logging.getLogger(__name__)
+
+USER_AGENT = "Mozilla/5.0 (compatible; US2KR/0.1; +https://github.com/us2kr) feed-reader"
+FEED_TIMEOUT_SECONDS = 10.0
+SUMMARY_MAX_CHARS = 300
+
+# (source label, feed url). Order matters only for tie-breaking on identical timestamps.
+FEEDS: list[tuple[str, str]] = [
+    ("CNBC Top News", "https://www.cnbc.com/id/100003114/device/rss/rss.html"),
+    ("CNBC Markets", "https://www.cnbc.com/id/20910258/device/rss/rss.html"),
+    ("MarketWatch Top Stories", "https://feeds.content.dowjones.io/public/rss/mw_topstories"),
+    ("MarketWatch MarketPulse", "https://feeds.content.dowjones.io/public/rss/mw_marketpulse"),
+    ("Yahoo Finance", "https://finance.yahoo.com/news/rssindex"),
+    ("Federal Reserve", "https://www.federalreserve.gov/feeds/press_all.xml"),
+    ("Investing.com", "https://www.investing.com/rss/news_25.rss"),
+]
+
+_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
+_WS_RE = re.compile(r"\s+")
+
+
+class _TextExtractor(HTMLParser):
+    """Minimal HTML -> text: keeps text nodes, drops tags/scripts/styles."""
+
+    def __init__(self) -> None:
+        super().__init__(convert_charrefs=True)
+        self._parts: list[str] = []
+        self._skip = 0
+
+    def handle_starttag(self, tag: str, attrs) -> None:
+        if tag in ("script", "style"):
+            self._skip += 1
+        elif tag in ("br", "p", "div", "li"):
+            self._parts.append(" ")
+
+    def handle_endtag(self, tag: str) -> None:
+        if tag in ("script", "style") and self._skip:
+            self._skip -= 1
+        elif tag in ("p", "div", "li"):
+            self._parts.append(" ")
+
+    def handle_data(self, data: str) -> None:
+        if not self._skip:
+            self._parts.append(data)
+
+    def text(self) -> str:
+        return "".join(self._parts)
+
+
+def strip_html(raw: str | None, max_chars: int = SUMMARY_MAX_CHARS) -> str:
+    """Remove tags/entities, collapse whitespace and truncate to `max_chars`."""
+    if not raw:
+        return ""
+    parser = _TextExtractor()
+    try:
+        parser.feed(raw)
+        parser.close()
+        text = parser.text()
+    except Exception:  # noqa: BLE001 - malformed HTML must not kill the collector
+        text = re.sub(r"<[^>]+>", " ", raw)
+    text = _WS_RE.sub(" ", html.unescape(text)).strip()
+    if len(text) > max_chars:
+        text = text[: max_chars - 1].rstrip() + "…"
+    return text
+
+
+def normalize_title(title: str) -> str:
+    """Lowercase, drop punctuation and collapse whitespace - used as a dedup key."""
+    return _WS_RE.sub(" ", _PUNCT_RE.sub("", title.lower())).strip()
+
+
+def _entry_datetime(entry) -> datetime | None:
+    """Return tz-aware UTC datetime from published_parsed / updated_parsed, else None."""
+    for key in ("published_parsed", "updated_parsed", "created_parsed"):
+        tm = entry.get(key)
+        if tm:
+            try:
+                return datetime.fromtimestamp(calendar.timegm(tm), tz=UTC)
+            except (OverflowError, ValueError, TypeError):
+                continue
+    return None
+
+
+def _fetch_feed_bytes(url: str) -> bytes:
+    resp = requests.get(url, timeout=FEED_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT})
+    resp.raise_for_status()
+    return resp.content
+
+
+def _parse_feed(source: str, content: bytes, since: datetime) -> list[NewsItem]:
+    parsed = feedparser.parse(content)
+    if parsed.get("bozo") and not parsed.entries:
+        raise ValueError(f"unparseable feed: {parsed.get('bozo_exception')}")
+    items: list[NewsItem] = []
+    for entry in parsed.entries:
+        title = _WS_RE.sub(" ", html.unescape(entry.get("title") or "")).strip()
+        link = (entry.get("link") or "").strip()
+        if not title or not link:
+            continue
+        published = _entry_datetime(entry)
+        if published is None or published < since:
+            continue
+        summary = strip_html(entry.get("summary") or entry.get("description") or "")
+        items.append(NewsItem(title=title, summary=summary, link=link, source=source, published=published))
+    return items
+
+
+def fetch_news(since: datetime, max_items: int = 40) -> list[NewsItem]:
+    """Collect headlines published at/after `since` from all feeds, deduplicated, newest first.
+
+    Raises RuntimeError only when every feed failed.
+    """
+    if since.tzinfo is None:
+        since = since.replace(tzinfo=UTC)
+    else:
+        since = since.astimezone(UTC)
+
+    collected: list[NewsItem] = []
+    failures: list[str] = []
+    for source, url in FEEDS:
+        try:
+            content = _fetch_feed_bytes(url)
+            items = _parse_feed(source, content, since)
+        except Exception as exc:  # noqa: BLE001 - per-feed isolation
+            log.warning("news feed %s failed: %s: %s", source, type(exc).__name__, str(exc)[:200])
+            failures.append(f"{source}: {type(exc).__name__}")
+            continue
+        log.info("news feed %s: %d items since %s", source, len(items), since.isoformat())
+        collected.extend(items)
+
+    if failures and len(failures) == len(FEEDS):
+        raise RuntimeError("all news feeds failed: " + "; ".join(failures))
+
+    seen_titles: set[str] = set()
+    seen_links: set[str] = set()
+    unique: list[NewsItem] = []
+    # Stable sort newest first before dedup so the freshest duplicate is the one kept.
+    for item in sorted(collected, key=lambda n: n.published, reverse=True):
+        tkey = normalize_title(item.title)
+        lkey = item.link.split("#", 1)[0].rstrip("/").lower()
+        if tkey in seen_titles or lkey in seen_links:
+            continue
+        seen_titles.add(tkey)
+        seen_links.add(lkey)
+        unique.append(item)
+
+    log.info(
+        "news: %d raw -> %d unique, returning %d (feeds failed: %d/%d)",
+        len(collected),
+        len(unique),
+        min(len(unique), max_items),
+        len(failures),
+        len(FEEDS),
+    )
+    return unique[:max_items]
diff --git a/us2kr/collectors/prices.py b/us2kr/collectors/prices.py
new file mode 100644
index 0000000..06d4228
--- /dev/null
+++ b/us2kr/collectors/prices.py
@@ -0,0 +1,335 @@
+"""Price collector: daily OHLCV via one batched yfinance download, plus best-effort after-hours prices.
+
+Semantics
+---------
+* ``close`` is the Close of the last daily bar dated on/before ``session_date`` (selected by date, never
+  by position, because the batch frame's index is the union of every ticker's dates and 24h instruments
+  such as ``KRW=X``/``BTC-USD``/futures carry bars for days on which US equities did not trade).
+* For fx / futures / crypto, which trade ~24h, "close" therefore means the last available daily bar on or
+  before the session date under Yahoo's own trading-day convention, not a 16:00 ET print.
+* ``prev_close`` is the Close of the preceding non-NaN daily bar; ``change_pct`` and ``change_abs`` derive
+  from those two. For the ``rates`` group (``^IRX ^FVX ^TNX ^TYX``) ``change_abs`` = yield points is the
+  primary metric (the renderer picks it via ``symbols.POINT_CHANGE_GROUPS``); ``change_pct`` is filled too.
+* ``avg_volume_10d`` averages the up-to-10 daily bars *before* the session bar (excludes the session day).
+* ``after_hours_price`` is filled only for plain equities/ETFs from 1-minute bars stamped at/after 16:00 ET
+  on the session date. It is best-effort: per-ticker try/except and a total time budget.
+* ``as_of`` is the exchange-local calendar date of the daily bar actually used, expressed as midnight
+  America/New_York, so a stale bar is visible.
+
+Failure policy: the batch download is retried once; if it still fails, a per-ticker ``Ticker.history`` loop
+is attempted for the ``indices`` group only; if nothing at all is obtained ``PriceFetchError`` is raised so
+``collectors.base.run_safely`` records a data gap. A single missing/bad ticker is skipped and logged.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import time
+from datetime import date, datetime, timedelta
+from datetime import time as dtime
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+import yfinance as yf
+
+from us2kr import symbols
+from us2kr.models import Quote
+
+log = logging.getLogger(__name__)
+
+ET = ZoneInfo("America/New_York")
+
+HISTORY_DAYS = 21  # calendar days of daily bars to pull (>= 10 sessions + holidays margin)
+AVG_VOLUME_WINDOW = 10  # sessions used for avg_volume_10d
+MIN_AVG_VOLUME_BARS = 3  # fewer prior bars than this -> avg_volume_10d = None
+AFTER_HOURS_BUDGET_SECONDS = 45.0  # total wall-clock budget for the after-hours loop
+REGULAR_CLOSE_ET = dtime(16, 0)  # after-hours bars are those stamped at/after this local time
+DOWNLOAD_TIMEOUT_SECONDS = 20
+
+_REQUIRED_COLS = ("Close",)
+_FALLBACK_GROUP = "indices"
+
+
+class PriceFetchError(RuntimeError):
+    """Raised when no price data at all could be obtained (batch + fallback both failed)."""
+
+
+# --------------------------------------------------------------------------------------------------
+# public API
+# --------------------------------------------------------------------------------------------------
+
+
+def fetch_quotes(session_date: date, tickers: list[str] | None = None) -> list[Quote]:
+    """Return one Quote per available ticker for the US session ``session_date``.
+
+    ``tickers`` defaults to ``symbols.all_tickers()``. Output order follows the input order. Tickers that
+    yield no usable bars are skipped (logged), never raised. Raises ``PriceFetchError`` only when the
+    whole batch and the fallback fail.
+    """
+    wanted = list(tickers) if tickers is not None else symbols.all_tickers()
+    if not wanted:
+        return []
+
+    frames = _download_history(session_date, wanted)
+
+    quotes: list[Quote] = []
+    for ticker in wanted:
+        frame = frames.get(ticker)
+        if frame is None or frame.empty:
+            log.warning("prices: %s missing from download - skipped", ticker)
+            continue
+        try:
+            quote = _quote_from_history(ticker, frame, session_date)
+        except Exception:  # one bad ticker must not sink the batch
+            log.exception("prices: %s could not be parsed - skipped", ticker)
+            continue
+        if quote is None:
+            log.warning("prices: %s has no bar on/before %s - skipped", ticker, session_date)
+            continue
+        quotes.append(quote)
+
+    log.info("prices: %d/%d tickers quoted for %s", len(quotes), len(wanted), session_date)
+    _fill_after_hours(quotes, session_date)
+    return quotes
+
+
+# --------------------------------------------------------------------------------------------------
+# download layer
+# --------------------------------------------------------------------------------------------------
+
+
+def _download_history(session_date: date, tickers: list[str]) -> dict[str, pd.DataFrame]:
+    """Batched daily download (retry once), then per-ticker fallback for indices. Never returns empty."""
+    start = session_date - timedelta(days=HISTORY_DAYS)
+    end = session_date + timedelta(days=1)  # yfinance ``end`` is exclusive
+    errors: list[str] = []
+
+    for attempt in (1, 2):
+        try:
+            raw = yf.download(
+                tickers,
+                start=start.isoformat(),
+                end=end.isoformat(),
+                interval="1d",
+                group_by="ticker",
+                auto_adjust=False,
+                threads=True,
+                progress=False,
+                timeout=DOWNLOAD_TIMEOUT_SECONDS,
+            )
+        except Exception as exc:  # noqa: BLE001 - network / parsing errors from yfinance
+            errors.append(f"batch attempt {attempt}: {type(exc).__name__}: {exc}")
+            log.warning("prices: batch download attempt %d failed: %s", attempt, exc)
+            continue
+        frames = _split_frame(raw, tickers)
+        if frames:
+            return frames
+        errors.append(f"batch attempt {attempt}: empty frame")
+        log.warning("prices: batch download attempt %d returned no data", attempt)
+
+    fallback_tickers = [t for t in tickers if symbols.group_of(t) == _FALLBACK_GROUP]
+    log.warning("prices: batch failed twice; falling back to per-ticker history for %s", fallback_tickers)
+    frames: dict[str, pd.DataFrame] = {}
+    for ticker in fallback_tickers:
+        try:
+            hist = yf.Ticker(ticker).history(
+                start=start.isoformat(),
+                end=end.isoformat(),
+                interval="1d",
+                auto_adjust=False,
+                actions=False,
+                timeout=DOWNLOAD_TIMEOUT_SECONDS,
+            )
+        except Exception as exc:  # noqa: BLE001
+            errors.append(f"{ticker}: {type(exc).__name__}: {exc}")
+            log.warning("prices: fallback history for %s failed: %s", ticker, exc)
+            continue
+        cleaned = _clean_single(hist)
+        if cleaned is not None:
+            frames[ticker] = cleaned
+    if frames:
+        return frames
+    raise PriceFetchError("no price data obtained; " + "; ".join(errors)[:500])
+
+
+def _split_frame(raw: pd.DataFrame | None, tickers: list[str]) -> dict[str, pd.DataFrame]:
+    """Turn a yf.download result into {ticker: single-level OHLCV frame}, dropping all-NaN tickers."""
+    if raw is None or not isinstance(raw, pd.DataFrame) or raw.empty:
+        return {}
+
+    frames: dict[str, pd.DataFrame] = {}
+    if isinstance(raw.columns, pd.MultiIndex):
+        # Expected layout (group_by="ticker"): level0 = ticker, level1 = price field.
+        # Defensive: if level0 holds price fields instead (group_by="column" layout), swap.
+        level0 = set(raw.columns.get_level_values(0))
+        level1 = set(raw.columns.get_level_values(1))
+        if "Close" in level0 and "Close" not in level1:
+            raw = raw.swaplevel(0, 1, axis=1)
+            level0 = set(raw.columns.get_level_values(0))
+        for ticker in tickers:
+            key = ticker if ticker in level0 else ticker.upper() if ticker.upper() in level0 else None
+            if key is None:
+                continue
+            cleaned = _clean_single(raw[key])
+            if cleaned is not None:
+                frames[ticker] = cleaned
+        return frames
+
+    # Single-level columns: only meaningful when exactly one ticker was requested.
+    if len(tickers) == 1:
+        cleaned = _clean_single(raw)
+        if cleaned is not None:
+            frames[tickers[0]] = cleaned
+    else:
+        log.warning("prices: single-level columns for %d tickers - cannot attribute, ignoring", len(tickers))
+    return frames
+
+
+def _clean_single(frame: pd.DataFrame | None) -> pd.DataFrame | None:
+    """Normalise a per-ticker OHLCV frame: naive date index, rows with a Close only; None if unusable."""
+    if frame is None or not isinstance(frame, pd.DataFrame) or frame.empty:
+        return None
+    if isinstance(frame.columns, pd.MultiIndex):
+        frame = frame.copy()
+        frame.columns = frame.columns.get_level_values(-1)
+    if any(col not in frame.columns for col in _REQUIRED_COLS):
+        return None
+    idx = pd.to_datetime(frame.index)
+    if idx.tz is not None:
+        # yfinance stamps daily bars at local midnight in the exchange tz (Europe/London for KRW=X/JPY=X,
+        # UTC for BTC-USD, America/New_York or America/Chicago for US indices). Converting to ET would move
+        # midnight-London/UTC bars to the previous evening ET and relabel them one day early, so just drop
+        # the tz and keep the exchange-local date, consistent with the tz-naive batch download.
+        idx = idx.tz_localize(None)
+    frame = frame.copy()
+    frame.index = idx.normalize()
+    frame = frame[frame["Close"].notna()]
+    if frame.empty:
+        return None
+    return frame.sort_index()
+
+
+# --------------------------------------------------------------------------------------------------
+# quote computation
+# --------------------------------------------------------------------------------------------------
+
+
+def _quote_from_history(ticker: str, frame: pd.DataFrame, session_date: date) -> Quote | None:
+    """Build a Quote from a cleaned daily frame using the last bar on/before ``session_date``."""
+    hist = frame[frame.index <= pd.Timestamp(session_date)]
+    if hist.empty:
+        return None
+
+    row = hist.iloc[-1]
+    bar_date: date = hist.index[-1].date()
+    if bar_date != session_date:
+        log.info("prices: %s has no %s bar; using last available bar %s", ticker, session_date, bar_date)
+
+    close = _num(row.get("Close"))
+    if close is None:
+        return None
+    prev_close = _num(hist.iloc[-2].get("Close")) if len(hist) >= 2 else None
+
+    change_abs = change_pct = None
+    if prev_close is not None:
+        change_abs = close - prev_close
+        if prev_close != 0:
+            change_pct = (close / prev_close - 1.0) * 100.0
+
+    volume = _num(row.get("Volume"))
+    if volume is not None and volume <= 0:
+        volume = None  # fx / rates / indices without volume report 0
+
+    prior_vol = hist["Volume"].iloc[-(AVG_VOLUME_WINDOW + 1) : -1] if "Volume" in hist.columns else None
+    avg_volume: float | None = None
+    if prior_vol is not None:
+        prior_vol = pd.to_numeric(prior_vol, errors="coerce").dropna()
+        prior_vol = prior_vol[prior_vol > 0]
+        if len(prior_vol) >= MIN_AVG_VOLUME_BARS:
+            avg_volume = float(prior_vol.mean())
+
+    return Quote(
+        symbol=ticker,
+        name=symbols.display_name(ticker),
+        group=symbols.group_of(ticker) or "other",
+        close=close,
+        prev_close=prev_close,
+        change_pct=change_pct,
+        change_abs=change_abs,
+        day_high=_num(row.get("High")),
+        day_low=_num(row.get("Low")),
+        volume=volume,
+        avg_volume_10d=avg_volume,
+        as_of=datetime.combine(bar_date, dtime.min, tzinfo=ET),
+        source="yfinance",
+    )
+
+
+def _num(value: object) -> float | None:
+    """Coerce a pandas scalar to float, mapping NaN/None/non-numeric to None."""
+    if value is None:
+        return None
+    try:
+        out = float(value)  # type: ignore[arg-type]
+    except (TypeError, ValueError):
+        return None
+    return None if math.isnan(out) or math.isinf(out) else out
+
+
+# --------------------------------------------------------------------------------------------------
+# after-hours (best effort)
+# --------------------------------------------------------------------------------------------------
+
+
+def _is_equity_like(ticker: str) -> bool:
+    """Plain equities/ETFs only (no indices ``^``, futures ``=F``, fx ``=X``, crypto ``-USD``, ``DX-Y.NYB``)."""
+    return ticker.isascii() and ticker.isalpha()
+
+
+def _fill_after_hours(quotes: list[Quote], session_date: date) -> None:
+    """Populate after_hours_price / after_hours_change_pct in place; never raises."""
+    deadline = time.monotonic() + AFTER_HOURS_BUDGET_SECONDS
+    attempted = filled = 0
+    for quote in quotes:
+        if quote.close is None or not _is_equity_like(quote.symbol):
+            continue
+        if time.monotonic() >= deadline:
+            log.warning("prices: after-hours budget exhausted after %d tickers", attempted)
+            break
+        attempted += 1
+        try:
+            price = _after_hours_price(quote.symbol, session_date)
+        except Exception as exc:  # noqa: BLE001 - best effort only
+            log.debug("prices: after-hours lookup failed for %s: %s", quote.symbol, exc)
+            continue
+        if price is None:
+            continue
+        quote.after_hours_price = price
+        quote.after_hours_change_pct = (price / quote.close - 1.0) * 100.0 if quote.close else None
+        filled += 1
+    if attempted:
+        log.info("prices: after-hours filled for %d/%d equity tickers", filled, attempted)
+
+
+def _after_hours_price(ticker: str, session_date: date) -> float | None:
+    """Last 1-minute Close stamped at/after 16:00 ET on ``session_date``; None when unavailable."""
+    hist = yf.Ticker(ticker).history(
+        start=session_date.isoformat(),
+        end=(session_date + timedelta(days=1)).isoformat(),
+        interval="1m",
+        prepost=True,
+        auto_adjust=False,
+        actions=False,
+        timeout=DOWNLOAD_TIMEOUT_SECONDS,
+    )
+    if hist is None or hist.empty or "Close" not in hist.columns:
+        return None
+    idx = pd.to_datetime(hist.index)
+    idx = idx.tz_convert(ET) if idx.tz is not None else idx.tz_localize(ET)
+    same_day = idx.date == session_date  # type: ignore[attr-defined]
+    post = hist[same_day & (idx.time >= REGULAR_CLOSE_ET)]  # type: ignore[attr-defined]
+    post = post[post["Close"].notna()]
+    if post.empty:
+        return None
+    return _num(post["Close"].iloc[-1])
diff --git a/us2kr/config.py b/us2kr/config.py
new file mode 100644
index 0000000..0ba0790
--- /dev/null
+++ b/us2kr/config.py
@@ -0,0 +1,81 @@
+"""Runtime settings (env / .env) - single source of truth for configuration.
+
+Symbols are NOT configured here; see `us2kr.symbols`.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from pathlib import Path
+from typing import Annotated
+
+from pydantic import Field, field_validator
+from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
+
+KST = "Asia/Seoul"
+ET = "America/New_York"
+
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(
+        env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
+    )
+
+    # --- MiniMax (OpenAI-compatible endpoint) ---
+    minimax_api_key: str = Field(default="", alias="MINIMAX_API_KEY")
+    minimax_base_url: str = Field(default="https://api.minimax.io/v1", alias="MINIMAX_BASE_URL")
+    minimax_model: str = Field(default="MiniMax-M3", alias="MINIMAX_MODEL")
+    minimax_fallback_models: Annotated[list[str], NoDecode] = Field(
+        default_factory=lambda: ["MiniMax-M2.7", "MiniMax-M2.5"], alias="MINIMAX_FALLBACK_MODELS"
+    )
+    minimax_max_completion_tokens: int = Field(default=16384, alias="MINIMAX_MAX_COMPLETION_TOKENS")
+    minimax_timeout_seconds: float = Field(default=180.0, alias="MINIMAX_TIMEOUT_SECONDS")
+
+    # --- Delivery ---
+    telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
+    telegram_chat_id: str | None = Field(default=None, alias="TELEGRAM_CHAT_ID")
+
+    # --- Optional data keys ---
+    fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY")
+
+    # --- Paths ---
+    data_dir: Path = Field(default=Path("data"), alias="US2KR_DATA_DIR")
+    reports_dir: Path = Field(default=Path("reports"), alias="US2KR_REPORTS_DIR")
+    log_dir: Path = Field(default=Path("logs"), alias="US2KR_LOG_DIR")
+
+    # --- Scheduling policy ---
+    run_offset_minutes: int = Field(default=75, alias="US2KR_RUN_OFFSET_MINUTES")
+    catchup_window_hours: int = Field(default=3, alias="US2KR_CATCHUP_WINDOW_HOURS")
+    # Skip only briefings that become pointless: a KRX holiday where the *next* US session closes before
+    # KRX trades again (e.g. mid-week Chuseok). Friday sessions are always delivered (used for Monday open).
+    skip_when_krx_closed: bool = Field(default=False, alias="US2KR_SKIP_WHEN_KRX_CLOSED")
+    # Ad-hoc KRX closures missing from exchange_calendars (e.g. election days), CSV of YYYY-MM-DD.
+    krx_extra_holidays: Annotated[list[date], NoDecode] = Field(default_factory=list, alias="US2KR_KRX_EXTRA_HOLIDAYS")
+    premarket_update_enabled: bool = Field(default=False, alias="US2KR_PREMARKET_UPDATE_ENABLED")
+    premarket_time_kst: str = Field(default="08:20", alias="US2KR_PREMARKET_TIME_KST")
+
+    # --- Collection policy ---
+    news_lookback_hours: int = Field(default=18, alias="US2KR_NEWS_LOOKBACK_HOURS")
+    news_max_items: int = Field(default=40, alias="US2KR_NEWS_MAX_ITEMS")
+
+    log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL")
+
+    @field_validator("minimax_fallback_models", "krx_extra_holidays", mode="before")
+    @classmethod
+    def _split_csv(cls, v):
+        if isinstance(v, str):
+            return [s.strip() for s in v.split(",") if s.strip()]
+        return v
+
+    @property
+    def db_path(self) -> Path:
+        return self.data_dir / "us2kr.sqlite3"
+
+    @property
+    def telegram_enabled(self) -> bool:
+        return bool(self.telegram_bot_token and self.telegram_chat_id)
+
+
+def get_settings() -> Settings:
+    """Load settings from environment / .env (called once per process by the CLI)."""
+    return Settings()
diff --git a/us2kr/daemon.py b/us2kr/daemon.py
new file mode 100644
index 0000000..c4be155
--- /dev/null
+++ b/us2kr/daemon.py
@@ -0,0 +1,334 @@
+"""Resident scheduler: catch-up on start, then one-shot briefing jobs computed from the exchange calendar."""
+
+from __future__ import annotations
+
+import logging
+import signal
+import threading
+from collections.abc import Callable
+from datetime import date, datetime, timedelta
+from typing import Any, Protocol
+from zoneinfo import ZoneInfo
+
+from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent
+from apscheduler.schedulers import SchedulerNotRunningError
+from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
+from apscheduler.triggers.interval import IntervalTrigger
+
+from us2kr.config import KST, Settings
+from us2kr.deliver import build_deliverers
+from us2kr.market_time import MarketClock
+from us2kr.pipeline import run_briefing
+from us2kr.store import Store
+
+log = logging.getLogger(__name__)
+
+BRIEFING_JOB_ID = "briefing"
+RECOVERY_JOB_ID = "briefing-recover"
+PREMARKET_JOB_ID = "premarket"
+HEARTBEAT_JOB_ID = "heartbeat"
+
+# None => APScheduler runs the one-shot briefing job whenever it becomes due, even hours late (machine
+# sleep, SIGSTOP, clock jump). With a finite grace APScheduler *drops* a late job instead of running it,
+# and a dropped one-shot job never reaches _briefing_job, whose `finally: schedule_next()` is what keeps
+# the chain of briefings alive. _briefing_job enforces the catch-up window itself.
+BRIEFING_MISFIRE_GRACE: int | None = None
+# A briefing whose run time is older than this and which is still recorded as "next" was either lost by
+# the scheduler or is running right now; the heartbeat re-arms the schedule in both cases (idempotent).
+HEARTBEAT_STALE_AFTER = timedelta(minutes=5)
+
+
+class SchedulerLike(Protocol):
+    """The subset of APScheduler's BaseScheduler the daemon relies on (fakeable in tests)."""
+
+    def add_job(self, func: Callable[..., Any], trigger: Any = None, **kwargs: Any) -> Any: ...
+
+    def add_listener(self, callback: Callable[[Any], None], mask: int = ...) -> None: ...
+
+    def shutdown(self, wait: bool = True) -> None: ...
+
+
+class Daemon:
+    """Wires clock, store and pipeline into a scheduler. Every collaborator is injectable."""
+
+    def __init__(
+        self,
+        settings: Settings,
+        *,
+        scheduler: SchedulerLike,
+        clock: MarketClock,
+        store: Store,
+        run_fn: Callable[[date], Any],
+        now_fn: Callable[[], datetime] | None = None,
+        deliverers_fn: Callable[[Settings], list[Any]] | None = None,
+        premarket_fn: Callable[[], Any] | None = None,
+    ) -> None:
+        self.settings = settings
+        self.scheduler = scheduler
+        self.clock = clock
+        self.store = store
+        self.run_fn = run_fn
+        self.now_fn = now_fn or (lambda: datetime.now(ZoneInfo(KST)))
+        self.deliverers_fn = deliverers_fn or build_deliverers
+        # Extension point: plug a real pre-market update here (PLAN.md P6).
+        self.premarket_fn = premarket_fn
+        self.next_run: tuple[date, datetime] | None = None
+        self.stop_requested = False
+        # Serialises pipeline runs (scheduled job / catch-up / heartbeat recovery) across executor threads.
+        self._run_lock = threading.Lock()
+
+    # ----------------------------------------------------------------- lifecycle
+    def start(self) -> None:
+        """Catch up a missed briefing, then schedule the next one plus auxiliary jobs."""
+        self.scheduler.add_listener(self._on_job_missed, EVENT_JOB_MISSED)
+        self.run_catchup()
+        if self.stop_requested:  # signal arrived during catch-up, before the scheduler was running
+            log.info("shutdown requested during catch-up - not scheduling jobs")
+            return
+        self.schedule_next()
+        self.schedule_premarket()
+        self.schedule_heartbeat()
+
+    def install_signal_handlers(self) -> None:
+        def _handler(signum: int, _frame: Any) -> None:
+            log.info("signal %s received - shutting down", signal.Signals(signum).name)
+            if not self.shutdown():
+                # The scheduler is not running yet (start-up catch-up in progress): the only way to stop
+                # the synchronous briefing is to unwind the main thread. run_catchup and the pipeline
+                # catch Exception only, so KeyboardInterrupt propagates up to run_daemon.
+                raise KeyboardInterrupt
+
+        try:
+            signal.signal(signal.SIGTERM, _handler)
+            signal.signal(signal.SIGINT, _handler)
+        except ValueError:  # not in the main thread
+            log.warning("signal handlers not installed (not main thread)")
+
+    def shutdown(self) -> bool:
+        """Request a stop. Returns True when a running scheduler was told to shut down, False otherwise."""
+        self.stop_requested = True
+        try:
+            self.scheduler.shutdown(wait=False)
+        except SchedulerNotRunningError:
+            log.debug("scheduler not running - nothing to shut down")
+            return False
+        except Exception:  # shutting down twice must not raise
+            log.debug("scheduler shutdown raised", exc_info=True)
+            return False
+        return True
+
+    # ------------------------------------------------------------------ catch-up
+    def run_catchup(self) -> bool:
+        """Run a briefing immediately if one was missed within the catch-up window. Returns True if run."""
+        now = self.now_fn()
+        candidate = self.clock.catchup_candidate(now, self.settings.catchup_window_hours)
+        if candidate is None:
+            log.info("no catch-up candidate at %s", now.isoformat(timespec="minutes"))
+            return False
+        krx_date = self.clock.krx_date_for(candidate)
+        if self.store.has_report(krx_date):
+            log.info("catch-up: briefing for %s (KRX %s) already sent", candidate, krx_date)
+            return False
+        if not self._run_lock.acquire(blocking=False):
+            log.info("catch-up: a briefing is already running - skipping")
+            return False
+        try:
+            log.info("catch-up: running missed briefing for session %s", candidate)
+            self.run_fn(candidate)
+        except Exception:  # daemon must keep going
+            log.exception("catch-up briefing for %s failed", candidate)
+            self._notify_error("US2KR catch-up 브리핑 실패", f"session={candidate}")
+        finally:
+            self._run_lock.release()
+        return True
+
+    # ---------------------------------------------------------------- scheduling
+    def schedule_next(self) -> tuple[date, datetime] | None:
+        """Compute the next briefing time from the calendar and (re)schedule the one-shot job."""
+        now = self.now_fn()
+        try:
+            session, run_at = self.clock.next_briefing(now)
+        except Exception:  # calendar failure: retry via heartbeat rather than die
+            log.exception("could not compute next briefing time")
+            self.next_run = None
+            return None
+        self.scheduler.add_job(
+            self._briefing_job,
+            DateTrigger(run_date=run_at),
+            args=[session, run_at],
+            id=BRIEFING_JOB_ID,
+            name=f"briefing {session}",
+            replace_existing=True,
+            misfire_grace_time=BRIEFING_MISFIRE_GRACE,
+        )
+        self.next_run = (session, run_at)
+        log.info("next briefing: US session %s at %s", session, run_at.isoformat(timespec="minutes"))
+        return self.next_run
+
+    def schedule_premarket(self) -> None:
+        if not self.settings.premarket_update_enabled:
+            return
+        hour, minute = _parse_hhmm(self.settings.premarket_time_kst)
+        self.scheduler.add_job(
+            self._premarket_job,
+            CronTrigger(day_of_week="mon-fri", hour=hour, minute=minute, timezone=ZoneInfo(KST)),
+            id=PREMARKET_JOB_ID,
+            name="premarket update",
+            replace_existing=True,
+            misfire_grace_time=600,
+        )
+        log.info("premarket update scheduled Mon-Fri %02d:%02d KST", hour, minute)
+
+    def schedule_heartbeat(self) -> None:
+        self.scheduler.add_job(
+            self._heartbeat_job,
+            IntervalTrigger(hours=1),
+            id=HEARTBEAT_JOB_ID,
+            name="heartbeat",
+            replace_existing=True,
+        )
+
+    # --------------------------------------------------------------------- jobs
+    def _briefing_job(self, session: date, run_at: datetime | None = None) -> None:
+        """Scheduled briefing. Late fires (sleep/wake) honour the catch-up window; ALWAYS reschedules."""
+        try:
+            if run_at is not None:
+                late = self.now_fn() - run_at
+                if late >= timedelta(hours=self.settings.catchup_window_hours):
+                    log.warning(
+                        "briefing for %s scheduled at %s fired %s late - outside catch-up window, skipping",
+                        session,
+                        run_at.isoformat(timespec="minutes"),
+                        late,
+                    )
+                    return
+                if late > HEARTBEAT_STALE_AFTER:
+                    log.warning("briefing for %s fired %s late - running (inside window)", session, late)
+            log.info("scheduled briefing starting for session %s", session)
+            with self._run_lock:
+                self.run_fn(session)
+        except Exception as exc:  # never let a job kill the scheduler
+            log.exception("scheduled briefing for %s failed", session)
+            self._notify_error("US2KR 브리핑 실패", f"session={session}: {type(exc).__name__}: {exc}"[:500])
+        finally:
+            self.schedule_next()
+
+    def _premarket_job(self) -> None:
+        if self.premarket_fn is None:
+            log.info("premarket update not yet implemented")
+            return
+        try:
+            self.premarket_fn()
+        except Exception:
+            log.exception("premarket update failed")
+
+    def _heartbeat_job(self) -> None:
+        """Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule."""
+        now = self.now_fn()
+        if self.next_run is None:
+            log.warning("heartbeat: no briefing scheduled - retrying schedule")
+            self.schedule_next()
+            return
+        session, run_at = self.next_run
+        if now - run_at > HEARTBEAT_STALE_AFTER:
+            # The one-shot job should have fired by now. Either it is running (run_catchup then finds
+            # the lock held and does nothing) or the scheduler lost it: catch up inside the window and
+            # re-arm. schedule_next() is idempotent, so re-arming during a run is harmless.
+            log.warning(
+                "heartbeat: briefing for %s at %s is stale - recovering schedule",
+                session,
+                run_at.isoformat(timespec="minutes"),
+            )
+            self._recover_schedule()
+            return
+        log.info("heartbeat: next briefing for %s at %s", session, run_at.isoformat(timespec="minutes"))
+
+    def _on_job_missed(self, event: JobExecutionEvent) -> None:
+        """EVENT_JOB_MISSED listener: a dropped one-shot briefing job must be replaced.
+
+        Cannot happen while BRIEFING_MISFIRE_GRACE is None, but kept as defence in depth. APScheduler
+        removes the exhausted one-shot job *after* dispatching this event (possibly on this very
+        thread, inside its job-processing loop), so re-adding a job with the same id here would be
+        deleted immediately. Defer the recovery to a separately named one-shot job instead.
+        """
+        if getattr(event, "job_id", None) != BRIEFING_JOB_ID:
+            return
+        log.warning(
+            "briefing job scheduled at %s was missed - queuing schedule recovery",
+            getattr(event, "scheduled_run_time", "?"),
+        )
+        try:
+            self.scheduler.add_job(
+                self._recover_schedule,
+                DateTrigger(run_date=self.now_fn()),
+                id=RECOVERY_JOB_ID,
+                name="briefing schedule recovery",
+                replace_existing=True,
+                misfire_grace_time=None,
+            )
+        except Exception:  # the heartbeat is the last backstop
+            log.exception("could not queue schedule recovery")
+
+    def _recover_schedule(self) -> None:
+        """Catch-up policy (window + store idempotency), then reschedule. Never raises."""
+        try:
+            self.run_catchup()
+        except Exception:  # clock/store failure must not kill the scheduler thread
+            log.exception("catch-up during schedule recovery failed")
+        finally:
+            self.schedule_next()
+
+    # ------------------------------------------------------------------ helpers
+    def _notify_error(self, subject: str, detail: str) -> None:
+        try:
+            for deliverer in self.deliverers_fn(self.settings):
+                try:
+                    deliverer.notify_error(subject, detail)
+                except Exception:
+                    log.exception("notify_error via %s failed", getattr(deliverer, "name", deliverer))
+        except Exception:  # building deliverers itself may fail
+            log.exception("could not build deliverers for error notification")
+
+
+def _parse_hhmm(value: str) -> tuple[int, int]:
+    try:
+        hh, mm = value.strip().split(":")
+        hour, minute = int(hh), int(mm)
+    except ValueError as exc:
+        raise ValueError(f"invalid HH:MM time: {value!r}") from exc
+    if not (0 <= hour <= 23 and 0 <= minute <= 59):
+        raise ValueError(f"invalid HH:MM time: {value!r}")
+    return hour, minute
+
+
+def run_daemon(settings: Settings) -> None:
+    """Build the real daemon and block until SIGTERM/SIGINT."""
+    scheduler = BlockingScheduler(timezone=ZoneInfo(KST))
+    clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
+    store = Store(settings.db_path)
+    daemon = Daemon(
+        settings,
+        scheduler=scheduler,
+        clock=clock,
+        store=store,
+        run_fn=lambda session: run_briefing(session, settings),
+    )
+    daemon.install_signal_handlers()
+    log.info(
+        "us2kr daemon starting (offset=%dmin, catch-up window=%dh)",
+        settings.run_offset_minutes,
+        settings.catchup_window_hours,
+    )
+    try:
+        daemon.start()
+        if daemon.stop_requested:
+            log.info("stop requested during start-up - not starting scheduler")
+        else:
+            scheduler.start()
+    except (KeyboardInterrupt, SystemExit):
+        daemon.shutdown()
+    finally:
+        store.close()
+        log.info("us2kr daemon stopped")
diff --git a/us2kr/deliver/__init__.py b/us2kr/deliver/__init__.py
new file mode 100644
index 0000000..c80c2c0
--- /dev/null
+++ b/us2kr/deliver/__init__.py
@@ -0,0 +1,24 @@
+"""Delivery channels: local file archive (always) + Telegram (when configured)."""
+
+from __future__ import annotations
+
+import logging
+
+from us2kr.config import Settings
+from us2kr.deliver.base import Deliverer, DeliveryError
+from us2kr.deliver.file import FileDeliverer
+from us2kr.deliver.telegram import TelegramDeliverer
+
+log = logging.getLogger(__name__)
+
+__all__ = ["Deliverer", "DeliveryError", "FileDeliverer", "TelegramDeliverer", "build_deliverers"]
+
+
+def build_deliverers(settings: Settings) -> list[Deliverer]:
+    """FileDeliverer always; TelegramDeliverer when both token and chat id are configured."""
+    deliverers: list[Deliverer] = [FileDeliverer(settings.reports_dir)]
+    if settings.telegram_enabled:
+        assert settings.telegram_bot_token and settings.telegram_chat_id  # guaranteed by telegram_enabled
+        deliverers.append(TelegramDeliverer(settings.telegram_bot_token, settings.telegram_chat_id))
+    log.info("deliverers: %s", ", ".join(d.name for d in deliverers))
+    return deliverers
diff --git a/us2kr/deliver/base.py b/us2kr/deliver/base.py
new file mode 100644
index 0000000..5f97480
--- /dev/null
+++ b/us2kr/deliver/base.py
@@ -0,0 +1,23 @@
+"""Delivery channel interface."""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+from us2kr.models import BriefingResult
+
+
+class DeliveryError(RuntimeError):
+    pass
+
+
+class Deliverer(Protocol):
+    name: str
+
+    def deliver(self, result: BriefingResult) -> str:
+        """Send the briefing. Return a short receipt string (message id / path). Raise DeliveryError."""
+        ...
+
+    def notify_error(self, subject: str, detail: str) -> None:
+        """Operator alert for pipeline failures. Must never raise."""
+        ...
diff --git a/us2kr/deliver/file.py b/us2kr/deliver/file.py
new file mode 100644
index 0000000..21f0a46
--- /dev/null
+++ b/us2kr/deliver/file.py
@@ -0,0 +1,52 @@
+"""Local archive deliverer: writes reports/YYYY-MM-DD.md (+ .json) keyed by payload.krx_date."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+from us2kr.config import KST
+from us2kr.deliver.base import DeliveryError
+from us2kr.models import BriefingResult
+
+log = logging.getLogger(__name__)
+
+
+class FileDeliverer:
+    """Archive each briefing as Markdown and as the full BriefingResult JSON."""
+
+    name = "file"
+
+    def __init__(self, reports_dir: Path) -> None:
+        self.reports_dir = Path(reports_dir)
+
+    def _ensure_dir(self) -> None:
+        self.reports_dir.mkdir(parents=True, exist_ok=True)
+
+    def deliver(self, result: BriefingResult) -> str:
+        """Write {krx_date}.md and {krx_date}.json; return the Markdown path."""
+        stem = result.payload.krx_date.isoformat()
+        md_path = self.reports_dir / f"{stem}.md"
+        json_path = self.reports_dir / f"{stem}.json"
+        try:
+            self._ensure_dir()
+            md_path.write_text(result.markdown, encoding="utf-8")
+            json_path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
+        except OSError as exc:
+            raise DeliveryError(f"file delivery failed for {stem}: {exc}") from exc
+        log.info("report archived: %s (+ .json)", md_path)
+        return str(md_path)
+
+    def notify_error(self, subject: str, detail: str) -> None:
+        """Append a timestamped line to {reports_dir}/errors.log. Never raises."""
+        try:
+            self._ensure_dir()
+            ts = datetime.now(ZoneInfo(KST)).isoformat(timespec="seconds")
+            flat_detail = " | ".join(part.strip() for part in detail.splitlines() if part.strip())
+            line = f"{ts}\t{subject}\t{flat_detail}\n"
+            with (self.reports_dir / "errors.log").open("a", encoding="utf-8") as fh:
+                fh.write(line)
+        except Exception:
+            log.exception("could not append to errors.log")
diff --git a/us2kr/deliver/telegram.py b/us2kr/deliver/telegram.py
new file mode 100644
index 0000000..0987b79
--- /dev/null
+++ b/us2kr/deliver/telegram.py
@@ -0,0 +1,161 @@
+"""Telegram Bot API deliverer (raw HTTPS via httpx; no bot framework).
+
+Security: the bot token is part of the request URL, so this module never logs URLs or raw
+exceptions unmasked and silences httpx's own INFO request log (which would print the URL).
+"""
+
+from __future__ import annotations
+
+import html
+import logging
+import re
+import time
+from typing import Any
+
+import httpx
+
+from us2kr.deliver.base import DeliveryError
+from us2kr.models import BriefingResult
+
+log = logging.getLogger(__name__)
+
+# httpx logs 'HTTP Request: POST https://api.telegram.org/bot/sendMessage ...' at INFO.
+logging.getLogger("httpx").setLevel(logging.WARNING)
+
+API_BASE = "https://api.telegram.org"
+MAX_ATTEMPTS = 3
+BACKOFF_BASE_SECONDS = 1.0
+_PARSE_ERROR_MARK = "can't parse entities"
+_TAG_RE = re.compile(r"]*>", re.IGNORECASE)
+
+
+def _to_plain(text: str) -> str:
+    """Strip the Telegram-HTML subset and unescape entities for a parse_mode-less fallback."""
+    return html.unescape(_TAG_RE.sub("", text))
+
+
+class TelegramDeliverer:
+    """Send the briefing as one or more HTML messages to a chat."""
+
+    name = "telegram"
+
+    def __init__(self, bot_token: str, chat_id: str, client: httpx.Client | None = None) -> None:
+        if not bot_token or not chat_id:
+            raise ValueError("TelegramDeliverer requires both bot_token and chat_id")
+        self._token = bot_token
+        self.chat_id = chat_id
+        self._client = client or httpx.Client(timeout=httpx.Timeout(30.0))
+        self._url = f"{API_BASE}/bot{bot_token}/sendMessage"
+
+    # ------------------------------------------------------------------ helpers
+    def _mask(self, text: str) -> str:
+        return text.replace(self._token, "***") if self._token else text
+
+    @staticmethod
+    def _retry_after(resp: httpx.Response, body: dict[str, Any] | None) -> float | None:
+        if body:
+            val = (body.get("parameters") or {}).get("retry_after")
+            if isinstance(val, (int, float)):
+                return float(val)
+        hdr = resp.headers.get("Retry-After")
+        if hdr:
+            try:
+                return float(hdr)
+            except ValueError:
+                return None
+        return None
+
+    @staticmethod
+    def _json(resp: httpx.Response) -> dict[str, Any] | None:
+        try:
+            body = resp.json()
+        except ValueError:
+            return None
+        return body if isinstance(body, dict) else None
+
+    def _send(self, text: str, parse_mode: str | None = "HTML") -> str:
+        """POST one sendMessage with retries. Return the message_id as a string."""
+        payload: dict[str, Any] = {
+            "chat_id": self.chat_id,
+            "text": text,
+            "disable_web_page_preview": True,
+        }
+        if parse_mode:
+            payload["parse_mode"] = parse_mode
+
+        last_error = "unknown"
+        for attempt in range(1, MAX_ATTEMPTS + 1):
+            backoff = BACKOFF_BASE_SECONDS * (2 ** (attempt - 1))
+            try:
+                resp = self._client.post(self._url, json=payload)
+            except httpx.HTTPError as exc:
+                last_error = f"{type(exc).__name__}: {self._mask(str(exc))}"
+                log.warning("telegram request error (attempt %d/%d): %s", attempt, MAX_ATTEMPTS, last_error)
+                if attempt < MAX_ATTEMPTS:
+                    time.sleep(backoff)
+                continue
+
+            body = self._json(resp)
+            description = str((body or {}).get("description", ""))
+            status = resp.status_code
+
+            if status == 200 and body and body.get("ok"):
+                message_id = (body.get("result") or {}).get("message_id")
+                log.info("telegram message sent (parse_mode=%s, id=%s)", parse_mode, message_id)
+                return str(message_id)
+
+            if status == 429:
+                wait = self._retry_after(resp, body) or backoff
+                last_error = f"429 rate limited: {description}"
+                log.warning("telegram 429 (attempt %d/%d), waiting %.1fs", attempt, MAX_ATTEMPTS, wait)
+                if attempt < MAX_ATTEMPTS:
+                    time.sleep(max(wait, backoff))
+                continue
+
+            if status >= 500:
+                last_error = f"{status} server error: {description}"
+                log.warning("telegram %d (attempt %d/%d): %s", status, attempt, MAX_ATTEMPTS, description)
+                if attempt < MAX_ATTEMPTS:
+                    time.sleep(backoff)
+                continue
+
+            if status == 400 and parse_mode and _PARSE_ERROR_MARK in description.lower():
+                log.warning("telegram rejected HTML (%s); resending chunk as plain text", description)
+                return self._send(_to_plain(text), parse_mode=None)
+
+            # Any other 4xx (or 200 with ok=false) is not retryable.
+            raise DeliveryError(
+                f"telegram sendMessage failed: HTTP {status}: {description or 'no description'}"
+            )
+
+        raise DeliveryError(f"telegram sendMessage failed after {MAX_ATTEMPTS} attempts: {last_error}")
+
+    # ---------------------------------------------------------------- interface
+    def deliver(self, result: BriefingResult) -> str:
+        """Send the rendered briefing in Telegram-safe HTML chunks; return first message_id."""
+        # Lazy import: render is an independent module and tests may monkeypatch it.
+        from us2kr.render import render_telegram_chunks
+
+        chunks = render_telegram_chunks(result.markdown)
+        if not chunks:
+            raise DeliveryError("telegram: nothing to send (render produced no chunks)")
+
+        first_id: str | None = None
+        for idx, chunk in enumerate(chunks, start=1):
+            log.debug("telegram sending chunk %d/%d (%d chars)", idx, len(chunks), len(chunk))
+            message_id = self._send(chunk, parse_mode="HTML")
+            if first_id is None:
+                first_id = message_id
+        log.info("telegram delivery complete: %d chunk(s), first id=%s", len(chunks), first_id)
+        return first_id or ""
+
+    def notify_error(self, subject: str, detail: str) -> None:
+        """Send a short plain-text operator alert. Never raises."""
+        text = f"⚠️ US2KR 오류: {subject}\n{detail[:1500]}"
+        try:
+            self._send(text, parse_mode=None)
+        except Exception as exc:  # noqa: BLE001 - alerts must never propagate
+            log.error("telegram notify_error failed: %s: %s", type(exc).__name__, self._mask(str(exc)))
+
+    def close(self) -> None:
+        self._client.close()
diff --git a/us2kr/llm/__init__.py b/us2kr/llm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/us2kr/llm/base.py b/us2kr/llm/base.py
new file mode 100644
index 0000000..c610e1d
--- /dev/null
+++ b/us2kr/llm/base.py
@@ -0,0 +1,17 @@
+"""LLM provider interface. Implementations must be side-effect free apart from the HTTP call."""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+from us2kr.models import MarketPayload, Report
+
+
+class LLMError(RuntimeError):
+    """Raised when every configured model failed to produce a valid Report."""
+
+
+class LLMProvider(Protocol):
+    def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
+        """Return (validated Report, model id that produced it). Raise LLMError on total failure."""
+        ...
diff --git a/us2kr/llm/minimax.py b/us2kr/llm/minimax.py
new file mode 100644
index 0000000..535d971
--- /dev/null
+++ b/us2kr/llm/minimax.py
@@ -0,0 +1,305 @@
+"""MiniMax provider: OpenAI-compatible chat completions with JSON-schema output and model fallback."""
+
+from __future__ import annotations
+
+import copy
+import json
+import logging
+import re
+from typing import Any, get_origin
+
+import openai
+from pydantic import ValidationError
+
+from us2kr.config import Settings
+from us2kr.llm.base import LLMError
+from us2kr.llm.prompts import SYSTEM_PROMPT, build_user_message
+from us2kr.models import MarketPayload, Report
+
+log = logging.getLogger(__name__)
+
+# Errors from the openai SDK that mean "this model/attempt failed; try the next model".
+OPENAI_ERRORS: tuple[type[Exception], ...] = (
+    openai.APIStatusError,
+    openai.APIConnectionError,
+    openai.APITimeoutError,
+    openai.RateLimitError,
+)
+
+_THINK_BLOCK = re.compile(r".*?", re.DOTALL)
+_CODE_FENCE = re.compile(r"```(?:json|JSON)?\s*(.*?)```", re.DOTALL)
+
+REPAIR_INSTRUCTION = (
+    "위 응답은 JSON 스키마 검증에 실패했습니다. 아래 오류를 참고하여, 설명 없이 스키마에 맞는 "
+    "완전한 JSON 객체 하나만 다시 출력하세요.\n\n오류:\n"
+)
+
+
+def _strip_titles(node: Any) -> None:
+    """Recursively remove JSON-schema "title" keys (noise for the model, not needed for validation)."""
+    if isinstance(node, dict):
+        node.pop("title", None)
+        for value in node.values():
+            _strip_titles(value)
+    elif isinstance(node, list):
+        for value in node:
+            _strip_titles(value)
+
+
+def report_json_schema() -> dict[str, Any]:
+    """JSON schema for `Report` suitable for `response_format` (no titles, closed object)."""
+    schema = copy.deepcopy(Report.model_json_schema())
+    _strip_titles(schema)
+    schema["additionalProperties"] = False
+    return schema
+
+
+def report_response_format() -> dict[str, Any]:
+    return {"type": "json_schema", "json_schema": {"name": "report", "schema": report_json_schema()}}
+
+
+def _first_json_object(text: str) -> str | None:
+    """Return the first balanced `{...}` block in `text` (string-aware), or None."""
+    start = text.find("{")
+    if start < 0:
+        return None
+    depth = 0
+    in_str = False
+    escape = False
+    for i in range(start, len(text)):
+        ch = text[i]
+        if in_str:
+            if escape:
+                escape = False
+            elif ch == "\\":
+                escape = True
+            elif ch == '"':
+                in_str = False
+            continue
+        if ch == '"':
+            in_str = True
+        elif ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+            if depth == 0:
+                return text[start : i + 1]
+    return None
+
+
+def extract_json_text(raw: str) -> str:
+    """Strip  blocks and code fences from a model reply, returning the JSON candidate text.
+
+    Handles: complete ... blocks anywhere, an unterminated  (take text after the
+    last , or the first {...} object), and ```json fences. Raises ValueError if nothing remains.
+    """
+    text = _THINK_BLOCK.sub("", raw)
+    if "" in text:
+        if "" in raw:
+            text = raw.rsplit("", 1)[1]
+        else:
+            text = _first_json_object(raw) or ""
+    fence = _CODE_FENCE.search(text)
+    if fence:
+        text = fence.group(1)
+    text = _first_json_object(text.strip()) or ""
+    if not text:
+        raise ValueError("no JSON object found in model reply")
+    return text
+
+
+_LIST_FIELDS: frozenset[str] = frozenset(
+    name for name, info in Report.model_fields.items() if get_origin(info.annotation) is list
+)
+_BULLET_SPLIT = re.compile(r"(?:^|\n)\s*(?:[-*•]|\d+[.)])\s+")
+_SENTENCE_SPLIT = re.compile(r"(?<=[.!?。])\s+")
+
+
+def _split_prose(text: str) -> list[str]:
+    """Split a prose string into list items: bullets/newlines first, then sentence boundaries."""
+    parts = [p.strip() for p in _BULLET_SPLIT.split(text) if p.strip()]
+    if len(parts) <= 1:
+        parts = [p.strip() for p in re.split(r"\n+", text) if p.strip()]
+    if len(parts) <= 1:
+        parts = [p.strip() for p in _SENTENCE_SPLIT.split(text) if p.strip()]
+    return parts or [text.strip()]
+
+
+def coerce_report_data(data: Any) -> tuple[Any, list[str]]:
+    """Fix the common MiniMax schema slip of emitting list fields as a single string.
+
+    Returns (data, names of coerced fields). Anything that is not a dict is returned untouched so that
+    pydantic can report the real error.
+    """
+    if not isinstance(data, dict):
+        return data, []
+    coerced: list[str] = []
+    for name in _LIST_FIELDS:
+        value = data.get(name)
+        if isinstance(value, str):
+            data[name] = _split_prose(value)
+            coerced.append(name)
+    return data, coerced
+
+
+def parse_report(raw: str) -> Report:
+    """Turn raw model content into a validated Report (raises ValueError / ValidationError)."""
+    text = extract_json_text(raw)
+    try:
+        data = json.loads(text)
+    except json.JSONDecodeError:
+        return Report.model_validate_json(text)  # let pydantic produce the canonical error
+    data, coerced = coerce_report_data(data)
+    if coerced:
+        log.warning("coerced string -> list for report fields: %s", ", ".join(sorted(coerced)))
+    return Report.model_validate(data)
+
+
+class MiniMaxProvider:
+    """LLMProvider backed by MiniMax's OpenAI-compatible endpoint with a model fallback chain."""
+
+    def __init__(self, settings: Settings, client: Any | None = None) -> None:
+        self._settings = settings
+        models = [settings.minimax_model, *settings.minimax_fallback_models]
+        self._models = list(dict.fromkeys(m for m in models if m))
+        if not self._models:
+            raise LLMError("no MiniMax model configured")
+        if client is None:
+            if not settings.minimax_api_key:
+                raise LLMError("MINIMAX_API_KEY is not set")
+            client = openai.OpenAI(
+                api_key=settings.minimax_api_key,
+                base_url=settings.minimax_base_url,
+                timeout=settings.minimax_timeout_seconds,
+                max_retries=2,
+            )
+        self._client = client
+        self._response_format = report_response_format()
+
+    @property
+    def models(self) -> list[str]:
+        return list(self._models)
+
+    # ------------------------------------------------------------------ public
+    def summarize(self, payload: MarketPayload) -> tuple[Report, str]:
+        user_message = build_user_message(payload)
+        base_messages: list[dict[str, Any]] = [
+            {"role": "system", "content": SYSTEM_PROMPT},
+            {"role": "user", "content": user_message},
+        ]
+        attempts: list[str] = []
+        for model in self._models:
+            result = self._attempt_model(model, base_messages, attempts)
+            if result is not None:
+                return result, model
+        raise LLMError("all MiniMax models failed: " + " | ".join(attempts))
+
+    # ----------------------------------------------------------------- helpers
+    def _attempt_model(
+        self, model: str, messages: list[dict[str, Any]], attempts: list[str]
+    ) -> Report | None:
+        """One model: initial call plus at most one repair retry. Returns None when the model failed."""
+        content = self._call(model, messages, attempts)
+        if content is None:
+            return None
+        try:
+            return parse_report(content)
+        except (ValidationError, ValueError) as exc:
+            error_text = _short(str(exc), 800)
+            attempts.append(f"{model}: invalid report ({_short(str(exc), 160)})")
+            log.warning(
+                "model %s returned invalid report, retrying once with repair hint: %s",
+                model,
+                _short(str(exc), 300),
+            )
+
+        repair_messages = [
+            *messages,
+            {"role": "assistant", "content": _short(content, 12000)},
+            {"role": "user", "content": REPAIR_INSTRUCTION + error_text},
+        ]
+        content = self._call(model, repair_messages, attempts, label="repair")
+        if content is None:
+            return None
+        try:
+            return parse_report(content)
+        except (ValidationError, ValueError) as exc:
+            attempts.append(f"{model} (repair): invalid report ({_short(str(exc), 160)})")
+            log.warning("model %s repair retry still invalid: %s", model, _short(str(exc), 300))
+            return None
+
+    def _call(
+        self, model: str, messages: list[dict[str, Any]], attempts: list[str], label: str = "initial"
+    ) -> str | None:
+        """Perform one chat completion. Returns content text, or None (attempt recorded) on failure."""
+        tag = model if label == "initial" else f"{model} ({label})"
+        extra_body: dict[str, Any] = {"reasoning_split": True}
+        if model.startswith("MiniMax-M3"):
+            extra_body["thinking"] = {"type": "adaptive"}
+        try:
+            completion = self._client.chat.completions.create(
+                model=model,
+                messages=messages,
+                max_completion_tokens=self._settings.minimax_max_completion_tokens,
+                response_format=self._response_format,
+                extra_body=extra_body,
+            )
+        except OPENAI_ERRORS as exc:
+            status = getattr(exc, "status_code", None)
+            detail = f"{type(exc).__name__}" + (f" {status}" if status else "") + f": {_short(str(exc), 160)}"
+            attempts.append(f"{tag}: {detail}")
+            log.warning("MiniMax call failed for %s: %s", tag, detail)
+            return None
+
+        choices = getattr(completion, "choices", None) or []
+        if not choices:
+            attempts.append(f"{tag}: empty choices")
+            log.warning("MiniMax %s returned no choices", tag)
+            return None
+        choice = choices[0]
+        finish_reason = getattr(choice, "finish_reason", None)
+        self._log_usage(model, completion, finish_reason)
+        if finish_reason == "length":
+            attempts.append(f"{tag}: truncated (finish_reason=length)")
+            log.warning("MiniMax %s output truncated (finish_reason=length)", tag)
+            return None
+        content = getattr(choice.message, "content", None)
+        if not content:
+            attempts.append(f"{tag}: empty content")
+            log.warning("MiniMax %s returned empty content", tag)
+            return None
+        return content
+
+    @staticmethod
+    def _log_usage(model: str, completion: Any, finish_reason: str | None) -> None:
+        usage = getattr(completion, "usage", None)
+        prompt_tokens = getattr(usage, "prompt_tokens", None)
+        completion_tokens = getattr(usage, "completion_tokens", None)
+        details = getattr(usage, "prompt_tokens_details", None)
+        cached = getattr(details, "cached_tokens", None) if details is not None else None
+        log.info(
+            "MiniMax response model=%s served_as=%s finish=%s prompt_tokens=%s completion_tokens=%s "
+            "cached_tokens=%s",
+            model,
+            getattr(completion, "model", None),
+            finish_reason,
+            prompt_tokens,
+            completion_tokens,
+            cached,
+        )
+
+
+def _short(text: str, limit: int) -> str:
+    text = " ".join(text.split())
+    return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+__all__ = [
+    "OPENAI_ERRORS",
+    "MiniMaxProvider",
+    "coerce_report_data",
+    "extract_json_text",
+    "parse_report",
+    "report_json_schema",
+    "report_response_format",
+]
diff --git a/us2kr/llm/prompts.py b/us2kr/llm/prompts.py
new file mode 100644
index 0000000..6aaba57
--- /dev/null
+++ b/us2kr/llm/prompts.py
@@ -0,0 +1,207 @@
+"""Prompt construction for the briefing LLM.
+
+`SYSTEM_PROMPT` is static (no dates, no timestamps) so that the provider's prompt cache can hit on
+every run. All per-day data goes into the user message built by `build_user_message`, which is a
+deterministic, human-readable serialisation of a `MarketPayload`.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import get_origin
+from zoneinfo import ZoneInfo
+
+from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report
+from us2kr.symbols import GROUP_TITLES_KO, POINT_CHANGE_GROUPS, SYMBOL_GROUPS
+
+_KST = ZoneInfo("Asia/Seoul")
+
+# Maximum characters of a news summary carried into the prompt (token budget control).
+NEWS_SUMMARY_MAX_CHARS = 160
+
+_GROUP_GLOSSARY = "\n".join(f"- {group}: {title}" for group, title in GROUP_TITLES_KO.items())
+
+
+def _is_list_field(annotation: object) -> bool:
+    return get_origin(annotation) is list
+
+
+def _field_guide() -> str:
+    lines = []
+    for name, info in Report.model_fields.items():
+        kind = "문자열 배열(각 항목 한 문장)" if _is_list_field(info.annotation) else "문자열"
+        lines.append(f"- {name}: {kind} - {info.description or ''}".rstrip(" -"))
+    return "\n".join(lines)
+
+
+def _skeleton() -> str:
+    parts = []
+    for name, info in Report.model_fields.items():
+        value = '["문장 1", "문장 2"]' if _is_list_field(info.annotation) else '"문장"'
+        parts.append(f'  "{name}": {value}')
+    return "{\n" + ",\n".join(parts) + "\n}"
+
+
+_FIELD_GUIDE = _field_guide()
+_SKELETON = _skeleton()
+
+SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 마감 브리핑 작성자입니다.
+미국 정규장이 마감된 직후, 한국 장이 열리기 전에 읽히는 브리핑을 작성합니다.
+
+## 작성 규칙
+1. 숫자는 사용자 메시지에 제공된 값만 사용합니다. 제공되지 않은 숫자(가격, 변동률, 예상치 등)를 새로 만들거나 추정하지 않습니다.
+   계산이 필요하면 제공된 값으로만 하고, 확실하지 않으면 숫자를 생략하고 방향(상승/하락/보합)만 서술합니다.
+2. 뉴스는 제공된 헤드라인·요약 범위 안에서만 언급합니다. 헤드라인만으로 확인되지 않은 배경이나 인과관계는
+   "보도에 따르면"으로 시작하는 문장으로 표기하고 단정하지 않습니다.
+3. 투자 조언(매수/매도/비중 조절 권고)을 하지 않습니다. 사실과 흐름을 정리하는 정보 요약 톤을 유지합니다.
+4. 한국 장 관점을 반드시 포함합니다: 반도체(SOX·SMH·엔비디아·마이크론·TSMC 등)의 흐름, 달러/원 환율, 한국 ADR과
+   MSCI Korea ETF(EWY)의 움직임, 미국 국채 금리 변화가 한국 시장에 주는 시사점을 다룹니다.
+5. 간결한 한국어 경어체(~습니다)로 작성합니다. 모든 필드는 한국어로만 작성하며 중국어·일본어·영어 문장을 섞지
+   않습니다(티커, 기관명, 약어 등 고유명사는 예외). 불필요한 수식어와 반복을 피합니다. 종목·지수 이름은 제공된
+   표기를 그대로 사용합니다.
+6. 오늘 한국 장이 휴장이면 "오늘 한국 휴장"임을 감안해 다음 거래일 관점으로 서술합니다.
+7. 수집에 실패해 제공되지 않은 항목은 언급을 피하고, 그 사실을 data_gaps에 그대로 적습니다.
+8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다.
+   배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지
+   않습니다.
+
+## 입력 데이터 용어집
+사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다.
+{_GROUP_GLOSSARY}
+
+각 시세 줄의 형식: `심볼 이름: 종가 (전일 대비)`. 전일 대비는 기본적으로 퍼센트(%)이며, 국채 금리는 수익률 변화폭(%p)입니다.
+`애프터` 표기는 애프터마켓 가격과 정규장 종가 대비 변동률입니다. 값이 없는 항목은 "자료 없음"으로 표시됩니다.
+"히스토리 노트"는 저장된 과거 데이터에서 계산한 연속 상승/하락, 평균 대비 변동 폭, 레벨 돌파 등의 참고 메모입니다.
+"경제지표"의 [발표]는 이미 발표된 지표, [예정]은 다음 하루 안에 예정된 이벤트입니다.
+"실적"의 amc는 장 마감 후 발표, bmo는 다음 개장 전 발표를 뜻합니다.
+
+## 출력 스키마 필드 안내 (필드명: 타입 - 내용)
+{_FIELD_GUIDE}
+
+출력 형태 예시(값은 자리표시자):
+{_SKELETON}
+"""
+
+
+def _fmt_num(value: float | None, decimals: int = 2) -> str:
+    if value is None:
+        return "자료 없음"
+    return f"{value:,.{decimals}f}"
+
+
+def _fmt_signed(value: float | None, decimals: int, suffix: str) -> str:
+    if value is None:
+        return "변동 자료 없음"
+    return f"{value:+.{decimals}f}{suffix}"
+
+
+def _fmt_time_kst(dt: datetime | None, with_date: bool = True) -> str:
+    if dt is None:
+        return "시간 미정"
+    if dt.tzinfo is not None:
+        dt = dt.astimezone(_KST)
+    fmt = "%Y-%m-%d %H:%M KST" if with_date else "%H:%M KST"
+    return dt.strftime(fmt)
+
+
+def _fmt_quote(q: Quote) -> str:
+    is_points = q.group in POINT_CHANGE_GROUPS
+    if is_points:
+        close = _fmt_num(q.close, 3)
+        change = _fmt_signed(q.change_abs, 3, "%p")
+    else:
+        close = _fmt_num(q.close, 2)
+        change = _fmt_signed(q.change_pct, 2, "%")
+    line = f"- {q.symbol} {q.name}: {close} ({change})"
+    if q.after_hours_price is not None:
+        ah_change = _fmt_signed(q.after_hours_change_pct, 2, "%")
+        line += f" | 애프터 {_fmt_num(q.after_hours_price, 2)} ({ah_change})"
+    return line
+
+
+def _group_order(groups: list[str]) -> list[str]:
+    known = [g for g in SYMBOL_GROUPS if g in groups]
+    unknown = sorted(g for g in groups if g not in SYMBOL_GROUPS)
+    return known + unknown
+
+
+def _fmt_news(item: NewsItem) -> list[str]:
+    lines = [f"- {item.title.strip()} | {item.source} | {_fmt_time_kst(item.published)}"]
+    summary = " ".join(item.summary.split())
+    if summary:
+        if len(summary) > NEWS_SUMMARY_MAX_CHARS:
+            summary = summary[: NEWS_SUMMARY_MAX_CHARS - 1] + "…"
+        lines.append(f"  요약: {summary}")
+    return lines
+
+
+def _fmt_event(ev: EconEvent) -> str:
+    tag = "발표" if ev.kind == "released" else "예정"
+    parts = [f"- [{tag}] {ev.name} ({ev.country}, 중요도 {ev.importance}, {_fmt_time_kst(ev.time)})"]
+    details = []
+    if ev.actual is not None:
+        details.append(f"실제 {ev.actual}")
+    if ev.forecast is not None:
+        details.append(f"예상 {ev.forecast}")
+    if ev.previous is not None:
+        details.append(f"이전 {ev.previous}")
+    if details:
+        parts.append(": " + " / ".join(details))
+    return "".join(parts)
+
+
+def _fmt_earnings(e: EarningsItem) -> str:
+    head = f"- {e.symbol} {e.name} ({e.report_date.isoformat()} {e.timing})"
+    details = []
+    if e.eps_actual is not None or e.eps_estimate is not None:
+        details.append(f"EPS 실제 {_fmt_num(e.eps_actual)} / 예상 {_fmt_num(e.eps_estimate)}")
+    if e.revenue_actual is not None or e.revenue_estimate is not None:
+        details.append(f"매출 실제 {_fmt_num(e.revenue_actual, 0)} / 예상 {_fmt_num(e.revenue_estimate, 0)}")
+    return head + (": " + ", ".join(details) if details else "")
+
+
+def _bullets(items: list[str]) -> list[str]:
+    return [f"- {item}" for item in items] or ["(없음)"]
+
+
+def build_user_message(payload: MarketPayload) -> str:
+    """Serialise the payload into a deterministic Korean text block for the user message."""
+    out: list[str] = []
+
+    out.append("## 세션 정보")
+    out.append(f"- 미국 거래일: {payload.session_date.isoformat()}")
+    out.append(f"- 미국 정규장 마감: {_fmt_time_kst(payload.us_close_time)}")
+    out.append(f"- 한국 거래일: {payload.krx_date.isoformat()}")
+    out.append(f"- 오늘 한국 장: {'개장' if payload.krx_open_today else '휴장'}")
+    out.append("")
+
+    out.append("## 시세")
+    by_group = payload.quotes_by_group()
+    if not by_group:
+        out.append("(시세 자료 없음)")
+    for group in _group_order(list(by_group)):
+        out.append(f"### {GROUP_TITLES_KO.get(group, group)}")
+        out.extend(_fmt_quote(q) for q in by_group[group])
+    out.append("")
+
+    out.append("## 히스토리 노트")
+    out.extend(_bullets(payload.history_notes))
+    out.append("")
+
+    out.append("## 뉴스")
+    news_lines = [line for item in payload.news for line in _fmt_news(item)]
+    out.extend(news_lines or ["(없음)"])
+    out.append("")
+
+    out.append("## 경제지표")
+    out.extend([_fmt_event(ev) for ev in payload.events] or ["(없음)"])
+    out.append("")
+
+    out.append("## 실적")
+    out.extend([_fmt_earnings(e) for e in payload.earnings] or ["(없음)"])
+    out.append("")
+
+    out.append("## 수집 누락")
+    out.extend(_bullets(payload.data_gaps))
+
+    return "\n".join(out) + "\n"
diff --git a/us2kr/logging_setup.py b/us2kr/logging_setup.py
new file mode 100644
index 0000000..b794de1
--- /dev/null
+++ b/us2kr/logging_setup.py
@@ -0,0 +1,131 @@
+"""Logging configuration: console + daily-rotating file, with API-key masking."""
+
+from __future__ import annotations
+
+import logging
+import re
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+from us2kr.config import Settings
+
+LOG_FILE_NAME = "us2kr.log"
+LOG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s"
+_HANDLER_TAG = "_us2kr_handler"
+
+# MiniMax keys (sk-...), Telegram bot tokens (123456:ABC...), and secrets passed as URL query parameters
+# (FRED sends api_key=<32 hex>; requests/urllib3 embed the full URL in HTTPError/ConnectionError messages,
+# which then land in tracebacks via log.exception). The parameter name is kept so the log stays readable.
+SECRET_RE = re.compile(
+    r"(sk-[A-Za-z0-9_-]{6,}|\d{6,}:[A-Za-z0-9_-]{20,})"
+    r"|(?i:([?&](?:api_key|apikey|api-key|key|token|access_token)=))[^&\s\"'<>]+"
+)
+MASK = "***"
+
+# Exact secret values registered by setup_logging(); replaced verbatim whatever their shape.
+_SECRET_VALUES: tuple[str, ...] = ()
+_MIN_SECRET_LEN = 8
+
+NOISY_LOGGERS = (
+    "httpx",
+    "httpx2",  # transport used by openai>=3 (logs request URLs at INFO)
+    "httpcore",
+    "apscheduler.executors",
+    "apscheduler.executors.default",
+    "yfinance",
+    "peewee",
+    "urllib3",
+)
+
+
+def register_secret_values(*values: str | None) -> None:
+    """Register configured secret values so they are masked even when they match no known shape.
+
+    Calling with no arguments clears the registry. Longest values first so a value that contains
+    another registered value leaves no residue.
+    """
+    global _SECRET_VALUES
+    _SECRET_VALUES = tuple(
+        sorted({v for v in values if v and len(v) >= _MIN_SECRET_LEN}, key=len, reverse=True)
+    )
+
+
+def _sub(m: re.Match[str]) -> str:
+    prefix = m.group(2)
+    return f"{prefix}{MASK}" if prefix else MASK
+
+
+def mask_secrets(text: str) -> str:
+    """Replace anything that looks like an API key / bot token / URL query secret with ``***``."""
+    for value in _SECRET_VALUES:
+        text = text.replace(value, MASK)
+    return SECRET_RE.sub(_sub, text)
+
+
+def _mask_value(value: object) -> object:
+    return mask_secrets(value) if isinstance(value, str) else value
+
+
+class SecretMaskingFilter(logging.Filter):
+    """Masks secrets in ``record.msg`` and ``record.args`` before formatting."""
+
+    def filter(self, record: logging.LogRecord) -> bool:
+        if isinstance(record.msg, str):
+            record.msg = mask_secrets(record.msg)
+        args = record.args
+        if isinstance(args, tuple):
+            record.args = tuple(_mask_value(a) for a in args)
+        elif isinstance(args, dict):
+            record.args = {k: _mask_value(v) for k, v in args.items()}
+        return True
+
+
+class SecretMaskingFormatter(logging.Formatter):
+    """Second line of defence: masks the fully formatted line (covers tracebacks too)."""
+
+    def format(self, record: logging.LogRecord) -> str:
+        return mask_secrets(super().format(record))
+
+
+def _remove_our_handlers(root: logging.Logger) -> None:
+    for handler in list(root.handlers):
+        if getattr(handler, _HANDLER_TAG, False):
+            root.removeHandler(handler)
+            handler.close()
+
+
+def setup_logging(settings: Settings) -> None:
+    """Configure the root logger. Safe to call more than once (handlers are replaced, not duplicated)."""
+    level_name = (settings.log_level or "INFO").upper()
+    level = logging.getLevelNamesMapping().get(level_name, logging.INFO)
+
+    root = logging.getLogger()
+    _remove_our_handlers(root)
+    root.setLevel(level)
+
+    register_secret_values(settings.minimax_api_key, settings.telegram_bot_token, settings.fred_api_key)
+    formatter = SecretMaskingFormatter(LOG_FORMAT)
+    mask_filter = SecretMaskingFilter()
+
+    console = logging.StreamHandler()
+    handlers: list[logging.Handler] = [console]
+
+    log_dir = Path(settings.log_dir)
+    try:
+        log_dir.mkdir(parents=True, exist_ok=True)
+        file_handler = TimedRotatingFileHandler(
+            log_dir / LOG_FILE_NAME, when="midnight", backupCount=14, encoding="utf-8"
+        )
+        handlers.append(file_handler)
+    except OSError as exc:  # unwritable log dir must not prevent start-up
+        logging.getLogger(__name__).warning("file logging disabled (%s): %s", log_dir, exc)
+
+    for handler in handlers:
+        handler.setLevel(level)
+        handler.setFormatter(formatter)
+        handler.addFilter(mask_filter)
+        setattr(handler, _HANDLER_TAG, True)
+        root.addHandler(handler)
+
+    for name in NOISY_LOGGERS:
+        logging.getLogger(name).setLevel(logging.WARNING)
diff --git a/us2kr/market_time.py b/us2kr/market_time.py
new file mode 100644
index 0000000..04681e1
--- /dev/null
+++ b/us2kr/market_time.py
@@ -0,0 +1,181 @@
+"""Exchange-calendar wrapper: US/KRX sessions, close times and briefing schedule.
+
+All date arithmetic goes through `exchange_calendars` (XNYS / XKRX) and `zoneinfo`; no UTC
+offsets are hardcoded, so DST switches, holidays and early closes are handled automatically.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Iterable
+from datetime import date, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+import exchange_calendars as xc
+import pandas as pd
+
+from us2kr.config import ET, KST
+
+log = logging.getLogger(__name__)
+
+ET_TZ = ZoneInfo(ET)
+KST_TZ = ZoneInfo(KST)
+
+_US_CAL = "XNYS"
+_KR_CAL = "XKRX"
+
+# KRX closures announced by KRX / the Korean government but missing from the pinned exchange_calendars
+# data (4.13.2). Election days and 임시공휴일 are decided after a library release, so the data lags.
+# Drop an entry once a release marks it as a holiday (the override is harmless while both agree).
+KNOWN_KRX_CLOSURES: frozenset[date] = frozenset(
+    {
+        date(2025, 10, 10),  # 임시공휴일 (추석 연휴 연장)
+        date(2026, 6, 3),  # 제9회 전국동시지방선거
+    }
+)
+
+
+class MarketClock:
+    """Session / briefing-time calculator backed by XNYS and XKRX calendars.
+
+    Calendars are built once for a window around `anchor` (default: today) and rebuilt
+    transparently if a query falls outside that window (long-running daemon safety).
+    """
+
+    def __init__(
+        self,
+        run_offset_minutes: int = 75,
+        *,
+        anchor: date | None = None,
+        years_back: int = 2,
+        years_ahead: int = 2,
+        extra_krx_holidays: Iterable[date] | None = None,
+    ) -> None:
+        if run_offset_minutes < 0:
+            raise ValueError("run_offset_minutes must be >= 0")
+        self.run_offset = timedelta(minutes=run_offset_minutes)
+        self._years_back = years_back
+        self._years_ahead = years_ahead
+        # Ad-hoc KRX closures on top of the library data (KNOWN_KRX_CLOSURES + operator-supplied dates).
+        self._krx_closures: frozenset[date] = KNOWN_KRX_CLOSURES | frozenset(extra_krx_holidays or ())
+        self._build(anchor or datetime.now(tz=KST_TZ).date())
+
+    # ------------------------------------------------------------------ calendars
+
+    # Pad the requested range so Jan 1 .. Dec 31 of the window always lie strictly inside
+    # [first_session, last_session] of both calendars (no exchange closes > ~5 days in a row).
+    _PAD = timedelta(days=10)
+
+    def _build(self, anchor: date) -> None:
+        start = date(anchor.year - self._years_back, 1, 1) - self._PAD
+        end = date(anchor.year + self._years_ahead, 12, 31) + self._PAD
+        self._xnys = xc.get_calendar(_US_CAL, start=start, end=end)
+        self._xkrx = xc.get_calendar(_KR_CAL, start=start, end=end)
+        # Bounds common to both calendars (sessions are tz-naive Timestamps in v4).
+        self._first = max(self._xnys.first_session, self._xkrx.first_session).date()
+        self._last = min(self._xnys.last_session, self._xkrx.last_session).date()
+        log.debug("calendars built: %s .. %s", self._first, self._last)
+
+    @staticmethod
+    def _as_date(d: date | datetime) -> date:
+        return d.date() if isinstance(d, datetime) else d
+
+    def _ts(self, d: date | datetime) -> pd.Timestamp:
+        """Normalise to a tz-naive midnight Timestamp inside the calendar range.
+
+        May rebuild `self._xnys` / `self._xkrx`, so callers must evaluate this BEFORE
+        binding a calendar method (`ts = self._ts(d); self._xnys.f(ts)`).
+        """
+        d = self._as_date(d)
+        if not (self._first <= d <= self._last):
+            log.info("date %s outside calendar window %s..%s; rebuilding", d, self._first, self._last)
+            self._build(d)
+        return pd.Timestamp(d)
+
+    @staticmethod
+    def _require_aware(now: datetime) -> datetime:
+        if now.tzinfo is None or now.utcoffset() is None:
+            raise ValueError("`now` must be a tz-aware datetime")
+        return now
+
+    # ------------------------------------------------------------------ sessions
+
+    def is_us_session(self, d: date) -> bool:
+        """True if XNYS trades on calendar date `d`."""
+        ts = self._ts(d)
+        return bool(self._xnys.is_session(ts))
+
+    def is_krx_session(self, d: date) -> bool:
+        """True if KRX trades on calendar date `d` (library data + known ad-hoc closures)."""
+        if self._as_date(d) in self._krx_closures:
+            return False
+        ts = self._ts(d)
+        return bool(self._xkrx.is_session(ts))
+
+    def next_krx_session(self, on_or_after: date) -> date:
+        """Earliest KRX session on or after `on_or_after` (ad-hoc closures respected)."""
+        d = self._as_date(on_or_after)
+        while True:
+            ts = self._ts(d)
+            d = self._xkrx.date_to_session(ts, direction="next").date()
+            if d not in self._krx_closures:
+                return d
+            d += timedelta(days=1)
+
+    def us_session_close(self, session: date) -> datetime:
+        """Regular-session close as tz-aware America/New_York datetime (early closes respected)."""
+        ts = self._ts(session)
+        if not self._xnys.is_session(ts):
+            raise ValueError(f"{self._as_date(session)} is not an XNYS session")
+        close_utc: pd.Timestamp = self._xnys.session_close(ts)
+        return close_utc.to_pydatetime().astimezone(ET_TZ)
+
+    def previous_us_session(self, before: date) -> date:
+        """Latest US session strictly before `before`."""
+        ts = self._ts(self._as_date(before) - timedelta(days=1))
+        return self._xnys.date_to_session(ts, direction="previous").date()
+
+    def next_us_session(self, after: date) -> date:
+        """Earliest US session strictly after `after`."""
+        ts = self._ts(self._as_date(after) + timedelta(days=1))
+        return self._xnys.date_to_session(ts, direction="next").date()
+
+    def latest_completed_session(self, now: datetime) -> date:
+        """Latest US session whose regular close is <= `now`."""
+        now = self._require_aware(now)
+        today_et = now.astimezone(ET_TZ).date()
+        ts = self._ts(today_et)
+        candidate = self._xnys.date_to_session(ts, direction="previous").date()
+        while self.us_session_close(candidate) > now:
+            candidate = self.previous_us_session(candidate)
+        return candidate
+
+    # ------------------------------------------------------------------ briefing schedule
+
+    def briefing_time_for(self, session: date) -> datetime:
+        """Briefing run time = session close + run offset, expressed in Asia/Seoul."""
+        return (self.us_session_close(session) + self.run_offset).astimezone(KST_TZ)
+
+    def krx_date_for(self, session: date) -> date:
+        """Korean calendar date on which the briefing for `session` is delivered."""
+        return self.briefing_time_for(session).date()
+
+    def next_briefing(self, now: datetime) -> tuple[date, datetime]:
+        """(session, run_at KST) for the earliest briefing strictly after `now`."""
+        now = self._require_aware(now)
+        today_et = now.astimezone(ET_TZ).date()
+        session = self.previous_us_session(today_et)
+        while self.briefing_time_for(session) <= now:
+            session = self.next_us_session(session)
+        return session, self.briefing_time_for(session)
+
+    def catchup_candidate(self, now: datetime, window_hours: int) -> date | None:
+        """Session whose briefing time lies in (now - window, now]; None when nothing was missed."""
+        now = self._require_aware(now)
+        window = timedelta(hours=window_hours)
+        session = self.latest_completed_session(now)
+        while True:
+            run_at = self.briefing_time_for(session)
+            if run_at <= now:
+                return session if run_at > now - window else None
+            session = self.previous_us_session(session)
diff --git a/us2kr/models.py b/us2kr/models.py
new file mode 100644
index 0000000..68532dd
--- /dev/null
+++ b/us2kr/models.py
@@ -0,0 +1,100 @@
+"""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 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
diff --git a/us2kr/pipeline.py b/us2kr/pipeline.py
new file mode 100644
index 0000000..f083824
--- /dev/null
+++ b/us2kr/pipeline.py
@@ -0,0 +1,286 @@
+"""Briefing pipeline: collect -> store -> summarise -> render -> deliver.
+
+All collaborators are imported at module level and referenced as module attributes so that tests
+can monkeypatch e.g. ``us2kr.pipeline.fetch_quotes``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from collections.abc import Sequence
+from datetime import date, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from pydantic import BaseModel
+
+from us2kr.collectors.base import run_safely
+from us2kr.collectors.events import fetch_earnings, fetch_events
+from us2kr.collectors.news import fetch_news
+from us2kr.collectors.prices import fetch_quotes
+from us2kr.config import KST, Settings
+from us2kr.deliver import build_deliverers
+from us2kr.llm.base import LLMError
+from us2kr.market_time import MarketClock
+from us2kr.models import BriefingResult, MarketPayload, Report
+from us2kr.render import render_markdown
+from us2kr.store import Store
+from us2kr.summarize import build_provider, summarize_payload
+from us2kr.symbols import SYMBOL_GROUPS
+
+log = logging.getLogger(__name__)
+
+EARNINGS_GROUPS: tuple[str, ...] = ("semis", "megacap", "korea_linked")
+# ETFs never report earnings; querying them only burns the earnings time budget.
+EARNINGS_EXCLUDE: frozenset[str] = frozenset({"EWY"})
+SKIP_ALREADY_SENT = "already sent"
+SKIP_KRX_CLOSED = "krx closed"
+DEGRADED_SUBJECT = "LLM 요약 실패 - 축소 모드 발송"
+DELIVERY_FAILED_SUBJECT = "브리핑 발송 실패"
+DELIVERY_FAILED_PREFIX = "delivery failed"
+
+
+def _now() -> datetime:
+    return datetime.now(ZoneInfo(KST))
+
+
+def earnings_tickers() -> list[str]:
+    """Tickers whose earnings we track (semis + megacap + Korea-linked), de-duplicated."""
+    seen: dict[str, None] = {}
+    for group in EARNINGS_GROUPS:
+        for ticker in SYMBOL_GROUPS.get(group, {}):
+            if ticker not in EARNINGS_EXCLUDE:
+                seen.setdefault(ticker, None)
+    return list(seen)
+
+
+def _to_json(items: Sequence[BaseModel]) -> str:
+    return json.dumps([m.model_dump(mode="json") for m in items], ensure_ascii=False, default=str)
+
+
+def _save_raw(store: Store, session: date, name: str, items: Sequence[BaseModel]) -> None:
+    """Archive a collector's raw output; failures are logged but never fatal."""
+    try:
+        store.save_raw(session, name, _to_json(items))
+    except Exception:  # archiving is best-effort
+        log.exception("save_raw(%s) failed", name)
+
+
+def build_payload(session: date, settings: Settings, clock: MarketClock, store: Store) -> MarketPayload:
+    """Run every collector with failure isolation and assemble the MarketPayload."""
+    now = _now()
+    us_close_time = clock.us_session_close(session)
+    krx_date = clock.krx_date_for(session)
+    krx_open_today = clock.is_krx_session(krx_date)
+    gaps: list[str] = []
+
+    quotes = run_safely("prices", lambda: fetch_quotes(session), [], gaps)
+    news_since = us_close_time - timedelta(hours=settings.news_lookback_hours)
+    news = run_safely(
+        "news", lambda: fetch_news(since=news_since, max_items=settings.news_max_items), [], gaps
+    )
+    events = run_safely("events", lambda: fetch_events(session, settings.fred_api_key), [], gaps)
+    tickers = earnings_tickers()
+    earnings = run_safely("earnings", lambda: fetch_earnings(session, tickers), [], gaps)
+
+    if quotes:
+        run_safely("store", lambda: store.save_quotes(session, quotes), None, gaps)
+    elif not any(g.startswith("prices:") for g in gaps):
+        gaps.append("prices: no quotes")
+    history_notes = run_safely("history", lambda: store.history_notes(session, quotes), [], gaps)
+
+    _save_raw(store, session, "prices", quotes)
+    _save_raw(store, session, "news", news)
+    _save_raw(store, session, "events", events)
+    _save_raw(store, session, "earnings", earnings)
+
+    payload = MarketPayload(
+        session_date=session,
+        us_close_time=us_close_time,
+        generated_at=now,
+        krx_date=krx_date,
+        krx_open_today=krx_open_today,
+        quotes=quotes,
+        news=news,
+        events=events,
+        earnings=earnings,
+        history_notes=history_notes,
+        data_gaps=gaps,
+    )
+    log.info(
+        "payload built for %s: %d quotes, %d news, %d events, %d earnings, %d gaps",
+        session,
+        len(quotes),
+        len(news),
+        len(events),
+        len(earnings),
+        len(gaps),
+    )
+    return payload
+
+
+def _minimal_payload(session: date, clock: MarketClock, krx_date: date, krx_open: bool, now: datetime):
+    return MarketPayload(
+        session_date=session,
+        us_close_time=clock.us_session_close(session),
+        generated_at=now,
+        krx_date=krx_date,
+        krx_open_today=krx_open,
+    )
+
+
+def _skip_result(payload: MarketPayload, reason: str) -> BriefingResult:
+    return BriefingResult(payload=payload, report=None, markdown="", error=reason)
+
+
+def _summarize(payload: MarketPayload, settings: Settings) -> tuple[Report | None, str | None, str | None]:
+    """Return (report, model_used, error). Any failure degrades instead of aborting the briefing."""
+    try:
+        provider = build_provider(settings)
+        report, model_used = summarize_payload(payload, provider)
+        return report, model_used, None
+    except LLMError as exc:
+        log.error("LLM summarisation failed, switching to degraded mode: %s", exc)
+        return None, None, str(exc)
+    except Exception as exc:  # provider construction / unexpected SDK errors
+        log.exception("unexpected LLM failure, switching to degraded mode")
+        return None, None, f"{type(exc).__name__}: {exc}"
+
+
+def _superseded_before_krx_open(session: date, krx_date: date, clock: MarketClock) -> bool:
+    """True when another US session closes before KRX next trades, so this briefing would be stale.
+
+    Friday session -> Saturday krx_date: next KRX = Monday, next US session = Monday -> NOT superseded
+    (Monday's open needs Friday's recap). Chuseok Wed 09-23 -> Thu 09-24 closed: next KRX Mon 09-28,
+    next US Thu 09-24 -> superseded -> skip.
+    """
+    return clock.next_us_session(session) < clock.next_krx_session(krx_date)
+
+
+def _notify_all(deliverers: Sequence[object], subject: str, detail: str) -> None:
+    for deliverer in deliverers:
+        try:
+            deliverer.notify_error(subject, detail)  # type: ignore[attr-defined]
+        except Exception:  # notify_error must never propagate
+            log.exception("notify_error via %s failed", getattr(deliverer, "name", deliverer))
+
+
+def _deliver(result: BriefingResult, settings: Settings, store: Store, report_id: int) -> list[str]:
+    """Deliver on every channel. Returns the names of channels that failed (empty on success).
+
+    Failures are isolated per channel but never silent: ``result.error`` is annotated (see
+    ``delivery_failed``) and an operator alert goes through every channel - FileDeliverer always writes
+    errors.log, and a broken Telegram channel may still accept the short plain-text alert.
+    """
+    deliverers = build_deliverers(settings)
+    failures: list[tuple[str, str]] = []
+    for deliverer in deliverers:
+        name = getattr(deliverer, "name", str(deliverer))
+        try:
+            receipt = deliverer.deliver(result)
+            store.mark_sent(report_id, name, receipt)
+            log.info("delivered via %s: %s", name, receipt)
+        except Exception as exc:  # one channel failing must not block the others
+            log.exception("delivery via %s failed", name)
+            failures.append((name, f"{type(exc).__name__}: {exc}"[:300]))
+
+    if result.degraded and result.error:
+        _notify_all(deliverers, DEGRADED_SUBJECT, result.error)
+
+    if failures:
+        detail = "; ".join(f"{name}: {err}" for name, err in failures)
+        note = f"{DELIVERY_FAILED_PREFIX}: {detail}"
+        result.error = f"{result.error} | {note}" if result.error else note
+        log.error("%s for KRX %s (report id=%d)", note, result.payload.krx_date, report_id)
+        _notify_all(
+            deliverers,
+            DELIVERY_FAILED_SUBJECT,
+            f"KRX {result.payload.krx_date} (report id={report_id}) - {detail} - "
+            f"재발송: us2kr run-once --force --date {result.payload.session_date.isoformat()}",
+        )
+    return [name for name, _ in failures]
+
+
+def delivery_failed(result: BriefingResult) -> bool:
+    """True when at least one delivery channel failed for this result."""
+    return bool(result.error) and DELIVERY_FAILED_PREFIX in (result.error or "")
+
+
+def run_briefing(
+    session: date | None,
+    settings: Settings,
+    *,
+    dry_run: bool = False,
+    force: bool = False,
+    no_llm: bool = False,
+) -> BriefingResult:
+    """Produce and deliver one briefing for ``session`` (default: latest completed US session)."""
+    clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
+    store = Store(settings.db_path)
+    try:
+        now = _now()
+        if session is None:
+            session = clock.latest_completed_session(now)
+        elif not clock.is_us_session(session):
+            raise ValueError(f"{session} is not a US trading session")
+
+        krx_date = clock.krx_date_for(session)
+        krx_open = clock.is_krx_session(krx_date)
+        log.info("briefing for US session %s -> KRX date %s (krx open: %s)", session, krx_date, krx_open)
+        close_at = clock.us_session_close(session)
+        if close_at > now:
+            # A not-yet-closed session must never reach save_quotes/save_report: the stored report would make
+            # the daemon's scheduled post-close run (and catch-up) skip as "already sent". --force does not
+            # bypass this (it means "resend an already-sent briefing"); test delivery with a past session.
+            if not dry_run:
+                close_txt = close_at.isoformat(timespec="minutes")
+                raise ValueError(
+                    f"US session {session} has not closed yet (close {close_txt}); refusing to save/deliver "
+                    "a partial briefing. Use --dry-run for an intraday preview, or --date  "
+                    "--force to test delivery."
+                )
+            log.warning("US session %s has not closed yet - dry-run preview with intraday quotes", session)
+
+        if settings.skip_when_krx_closed and not krx_open:
+            if _superseded_before_krx_open(session, krx_date, clock):
+                log.info("KRX closed on %s; a later US session closes before it reopens - skipping", krx_date)
+                payload = _minimal_payload(session, clock, krx_date, krx_open, now)
+                return _skip_result(payload, SKIP_KRX_CLOSED)
+            log.info("KRX closed on %s but this is the last US session before it reopens - sending", krx_date)
+        if not dry_run and not force and store.has_report(krx_date):
+            log.info("briefing for %s already sent - skipping (use --force to resend)", krx_date)
+            return _skip_result(_minimal_payload(session, clock, krx_date, krx_open, now), SKIP_ALREADY_SENT)
+
+        payload = build_payload(session, settings, clock, store)
+
+        report: Report | None = None
+        model_used: str | None = None
+        error: str | None = None
+        degraded = False
+        if no_llm:
+            degraded = True
+            log.info("--no-llm: skipping summarisation")
+        else:
+            report, model_used, error = _summarize(payload, settings)
+            degraded = report is None
+
+        markdown = render_markdown(payload, report, model_used)
+        result = BriefingResult(
+            payload=payload,
+            report=report,
+            markdown=markdown,
+            model_used=model_used,
+            degraded=degraded,
+            error=error,
+        )
+        if dry_run:
+            print(markdown)
+            return result
+
+        # Save before delivering: a crash mid-delivery must not cause a duplicate on catch-up.
+        report_id = store.save_report(result)
+        log.info("report %d saved for %s (degraded=%s)", report_id, krx_date, degraded)
+        _deliver(result, settings, store, report_id)
+        return result
+    finally:
+        store.close()
diff --git a/us2kr/render.py b/us2kr/render.py
new file mode 100644
index 0000000..eeb108a
--- /dev/null
+++ b/us2kr/render.py
@@ -0,0 +1,435 @@
+"""Rendering: MarketPayload (+ optional LLM Report) -> Korean Markdown, and Markdown -> Telegram HTML chunks.
+
+Every number in the output comes from the payload. The Report is used for prose only.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from datetime import UTC, datetime
+from urllib.parse import quote
+from zoneinfo import ZoneInfo
+
+from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report
+from us2kr.symbols import GROUP_TITLES_KO, POINT_CHANGE_GROUPS, SYMBOL_GROUPS
+
+KST = ZoneInfo("Asia/Seoul")
+MAX_NEWS_ITEMS = 12
+DASH = "—"
+
+_TIMING_KO = {"amc": "장마감후", "bmo": "장전", "unknown": ""}
+
+
+# ------------------------------------------------------------------ formatting helpers
+
+
+def _kst(dt: datetime) -> datetime:
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=UTC)
+    return dt.astimezone(KST)
+
+
+def _fmt_num(v: float | None, decimals: int = 2) -> str:
+    return DASH if v is None else f"{v:,.{decimals}f}"
+
+
+def _fmt_pct(v: float | None) -> str:
+    if v is None:
+        return DASH
+    if abs(v) < 0.005:  # avoid printing "-0.00%"
+        v = 0.0
+    return f"{v:+.2f}%"
+
+
+def _fmt_bp(change_abs: float | None) -> str:
+    return DASH if change_abs is None else f"{change_abs * 100:+.1f}bp"
+
+
+def _fmt_money(v: float | None) -> str:
+    if v is None:
+        return DASH
+    if abs(v) >= 1e9:
+        return f"{v / 1e9:,.2f}B"
+    if abs(v) >= 1e6:
+        return f"{v / 1e6:,.1f}M"
+    return f"{v:,.2f}"
+
+
+def _cell(text: str) -> str:
+    """Escape a Markdown table cell (pipes, newlines)."""
+    return text.replace("|", "\\|").replace("\n", " ").strip()
+
+
+def _text(s: object) -> str:
+    """Normalise a prose string from the Report: strip, collapse whitespace/newlines."""
+    return re.sub(r"\s*\n\s*", " ", str(s or "")).strip()
+
+
+def _link_text(s: str) -> str:
+    return _text(s).replace("[", "(").replace("]", ")")
+
+
+# ------------------------------------------------------------------ Markdown sections
+
+
+def _header(payload: MarketPayload, report: Report | None) -> list[str]:
+    lines = [
+        f"# 🇺🇸→🇰🇷 미국 마감 브리핑 — {payload.krx_date.isoformat()} (미 {payload.session_date.isoformat()} 세션)"
+    ]
+    close_kst = _kst(payload.us_close_time).strftime("%Y-%m-%d %H:%M")
+    gen_kst = _kst(payload.generated_at).strftime("%Y-%m-%d %H:%M")
+    lines.append(f"미국 마감: {close_kst} KST · 생성: {gen_kst} KST")
+    if not payload.krx_open_today:
+        lines.append("⚠️ 오늘 한국 휴장")
+    if report is None:
+        lines.append("🧩 축소 모드(LLM 요약 없음)")
+    return lines
+
+
+def _bullets(items: list[str]) -> list[str]:
+    return [f"- {_text(it)}" for it in items if _text(it)]
+
+
+def _section(title: str, body: list[str]) -> list[str]:
+    if not body:
+        return []
+    return [f"## {title}", *body, ""]
+
+
+def _report_sections(report: Report) -> list[str]:
+    out: list[str] = []
+    if _text(report.headline):
+        out += _section("총평", [f"**{_text(report.headline)}**"])
+    if _text(report.market_overview):
+        out += _section("시장 개요", [_text(report.market_overview)])
+    out += _section("주요 요인", _bullets(report.key_drivers))
+    if _text(report.rates_fx_commodities):
+        out += _section("금리·환율·원자재", [_text(report.rates_fx_commodities)])
+    out += _section("한국 장 시사점", _bullets(report.korea_implications))
+    out += _section("오늘 볼 것", _bullets(report.watchlist_today))
+    out += _section("리스크", _bullets(report.risk_flags))
+    return out
+
+
+def _ordered_groups(payload: MarketPayload) -> list[tuple[str, str, list[Quote]]]:
+    """(group key, Korean title, quotes) in GROUP_TITLES_KO order; unknown groups appended."""
+    by_group = payload.quotes_by_group()
+    ordered: list[tuple[str, str, list[Quote]]] = []
+    for g, title in GROUP_TITLES_KO.items():
+        if g in by_group:
+            ordered.append((g, title, _order_quotes(g, by_group[g])))
+    for g, qs in by_group.items():
+        if g not in GROUP_TITLES_KO:
+            ordered.append((g, g, qs))
+    return ordered
+
+
+def _order_quotes(group: str, quotes: list[Quote]) -> list[Quote]:
+    order = list(SYMBOL_GROUPS.get(group, {}))
+    rank = {t: i for i, t in enumerate(order)}
+    return sorted(quotes, key=lambda q: (rank.get(q.symbol, len(rank)), q.symbol))
+
+
+def _quote_table(group: str, quotes: list[Quote]) -> list[str]:
+    points = group in POINT_CHANGE_GROUPS
+    has_after = any(q.after_hours_price is not None for q in quotes)
+    header = ["종목", "종가", "변동"] + (["애프터"] if has_after else [])
+    lines = ["| " + " | ".join(header) + " |", "|" + "|".join("---" for _ in header) + "|"]
+    for q in quotes:
+        name = _cell(q.name or q.symbol)
+        if points:
+            close = _fmt_num(q.close, 3)
+            change = _fmt_bp(q.change_abs)
+        else:
+            close = _fmt_num(q.close, 2)
+            change = _fmt_pct(q.change_pct)
+        cells = [name, close, change]
+        if has_after:
+            if q.after_hours_price is None:
+                cells.append(DASH)
+            else:
+                ah = _fmt_num(q.after_hours_price, 3 if points else 2)
+                if q.after_hours_change_pct is not None:
+                    ah += f" ({_fmt_pct(q.after_hours_change_pct)})"
+                cells.append(ah)
+        lines.append("| " + " | ".join(cells) + " |")
+    return lines
+
+
+# Normal URL punctuation and existing %XX escapes stay as-is; '(' ')' and whitespace become %28 %29 %20 so
+# they cannot terminate the Markdown ``(url)`` destination early (see _LINK_RE).
+_URL_SAFE = ":/?#[]@!$&'*+,;=%"
+
+
+def _link_url(url: str) -> str:
+    """Percent-encode characters that would break a Markdown / Telegram link destination."""
+    return quote(url.strip(), safe=_URL_SAFE)
+
+
+def _news_lines(news: list[NewsItem]) -> list[str]:
+    lines = []
+    for n in news[:MAX_NEWS_ITEMS]:
+        when = _kst(n.published).strftime("%H:%M")
+        lines.append(f"- [{_link_text(n.title)}]({_link_url(n.link)}) — {_text(n.source)}, {when} KST")
+    return lines
+
+
+def _event_line(e: EconEvent) -> str:
+    star = "★ " if e.importance == "high" else ""
+    when = f" {_kst(e.time).strftime('%m/%d %H:%M')} KST" if e.time else ""
+    if e.kind == "released":
+        return (
+            f"- [발표{when}] {star}{_text(e.name)}: 실제 {e.actual or DASH} / 예상 {e.forecast or DASH}"
+            f" / 이전 {e.previous or DASH}"
+        )
+    tail = f" (예상 {e.forecast})" if e.forecast else ""
+    return f"- [예정{when}] {star}{_text(e.name)}{tail}"
+
+
+def _earnings_line(it: EarningsItem) -> str:
+    timing = _TIMING_KO.get(it.timing, "")
+    tag = f"실적·{timing}" if timing else "실적"
+    parts = []
+    if it.eps_actual is not None or it.eps_estimate is not None:
+        eps = f"EPS {_fmt_num(it.eps_actual)}"
+        if it.eps_estimate is not None:
+            eps += f" (예상 {_fmt_num(it.eps_estimate)})"
+        parts.append(eps)
+    if it.revenue_actual is not None or it.revenue_estimate is not None:
+        rev = f"매출 {_fmt_money(it.revenue_actual)}"
+        if it.revenue_estimate is not None:
+            rev += f" (예상 {_fmt_money(it.revenue_estimate)})"
+        parts.append(rev)
+    detail = " · ".join(parts) if parts else "발표 예정"
+    return f"- [{tag}] {_text(it.name)}({it.symbol}) {it.report_date.isoformat()}: {detail}"
+
+
+def render_markdown(payload: MarketPayload, report: Report | None, model_used: str | None) -> str:
+    """Render the Korean Markdown briefing. Works with ``report=None`` (degraded: tables only)."""
+    lines: list[str] = _header(payload, report)
+    lines.append("")
+    if report is not None:
+        lines += _report_sections(report)
+    for g, title, qs in _ordered_groups(payload):
+        lines += _section(f"📊 {title}", _quote_table(g, qs))
+    lines += _section("히스토리 노트", _bullets(payload.history_notes))
+    lines += _section("주요 뉴스", _news_lines(payload.news))
+    ev_lines = [_event_line(e) for e in payload.events] + [_earnings_line(e) for e in payload.earnings]
+    lines += _section("경제지표·실적", ev_lines)
+    lines += _section("수집 누락", _bullets(payload.data_gaps))
+    lines += ["---", f"요약 모델: {model_used or '없음'}"]
+    return "\n".join(lines).rstrip() + "\n"
+
+
+# ------------------------------------------------------------------ Telegram HTML
+
+_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
+_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
+_HEADER_RE = re.compile(r"^(#{1,6})\s+(.*)$")
+_BULLET_RE = re.compile(r"^\s*[-*]\s+")
+_HR_RE = re.compile(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$")
+_SEP_CELL_RE = re.compile(r":?-{2,}:?")
+_PRE_OPEN, _PRE_CLOSE = "
", "
" + +_Unit = tuple[str, list[str]] # ("text", [html_line]) | ("pre", [escaped_lines]) + + +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _inline(md: str) -> str: + """Escape HTML and convert inline Markdown (links, bold) to Telegram HTML.""" + out: list[str] = [] + pos = 0 + for m in _LINK_RE.finditer(md): + out.append(_esc(md[pos : m.start()])) + url = _esc(m.group(2)).replace('"', """) + out.append(f'
{_esc(m.group(1))}') + pos = m.end() + out.append(_esc(md[pos:])) + return _BOLD_RE.sub(r"\1", "".join(out)) + + +def _width(s: str) -> int: + """Approximate display width (CJK / emoji count as 2 columns).""" + w = 0 + for ch in s: + if unicodedata.combining(ch): + continue + if unicodedata.east_asian_width(ch) in ("W", "F") or ord(ch) >= 0x1F000: + w += 2 + else: + w += 1 + return w + + +def _split_row(line: str) -> list[str]: + inner = line.strip().removeprefix("|") + if inner.endswith("|") and not inner.endswith("\\|"): + inner = inner[:-1] + return [c.strip().replace("\\|", "|") for c in re.split(r"(? list[str]: + rows: list[list[str]] = [] + for r in md_rows: + cells = _split_row(r) + if cells and all(_SEP_CELL_RE.fullmatch(c) for c in cells if c): + continue + rows.append(cells) + if not rows: + return [] + ncol = max(len(r) for r in rows) + rows = [r + [""] * (ncol - len(r)) for r in rows] + widths = [max(_width(r[i]) for r in rows) for i in range(ncol)] + out = [] + for r in rows: + cells = [] + for i, c in enumerate(r): + pad = " " * (widths[i] - _width(c)) + cells.append(c + pad if i == 0 else pad + c) + out.append(_esc(" ".join(cells).rstrip())) + return out + + +def _to_units(lines: list[str]) -> list[_Unit]: + units: list[_Unit] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.lstrip().startswith("|"): + table = [] + while i < len(lines) and lines[i].lstrip().startswith("|"): + table.append(lines[i]) + i += 1 + pre = _format_table(table) + if pre: + units.append(("pre", pre)) + continue + i += 1 + if _HR_RE.match(line): + continue + if not line.strip(): + if units and units[-1] == ("text", [""]): + continue # collapse blank runs + units.append(("text", [""])) + continue + m = _HEADER_RE.match(line) + if m: + units.append(("text", [f"{_inline(m.group(2).strip())}"])) + elif _BULLET_RE.match(line): + units.append(("text", ["• " + _inline(_BULLET_RE.sub("", line, count=1))])) + else: + units.append(("text", [_inline(line)])) + while units and units[0] == ("text", [""]): + units.pop(0) + while units and units[-1] == ("text", [""]): + units.pop() + return units + + +def _split_sections(markdown: str) -> list[list[str]]: + sections: list[list[str]] = [] + cur: list[str] = [] + for line in markdown.splitlines(): + if line.startswith("## ") and cur: + sections.append(cur) + cur = [] + cur.append(line) + if cur: + sections.append(cur) + return sections + + +def _units_html(units: list[_Unit]) -> str: + parts: list[str] = [] + for kind, lines in units: + if kind == "pre": + parts.append("\n".join([_PRE_OPEN, *lines, _PRE_CLOSE])) + else: + parts.extend(lines) + return "\n".join(parts) + + +def _joined_len(lines: list[str]) -> int: + return sum(len(x) for x in lines) + max(len(lines) - 1, 0) + + +def _hard_split(line: str, limit: int) -> list[str]: + return [line[i : i + limit] for i in range(0, len(line), limit)] or [""] + + +def _pack_units(units: list[_Unit], limit: int) -> list[str]: + """Pack one oversized section line by line;
 blocks are closed/reopened across chunks."""
+    chunks: list[str] = []
+    buf: list[str] = []
+
+    def flush() -> None:
+        while buf and not buf[-1]:
+            buf.pop()
+        if buf:
+            chunks.append("\n".join(buf))
+        buf.clear()
+
+    for kind, lines in units:
+        if kind == "text":
+            for line in lines:
+                if not line and not buf:
+                    continue
+                if _joined_len([*buf, line]) > limit:
+                    flush()
+                if len(line) > limit:
+                    for piece in _hard_split(line, limit):
+                        flush()
+                        buf.append(piece)
+                    continue
+                buf.append(line)
+            continue
+        pre: list[str] = []
+        for line in lines:
+            if _joined_len([*buf, _PRE_OPEN, *pre, line, _PRE_CLOSE]) <= limit:
+                pre.append(line)
+                continue
+            if pre:
+                buf.extend([_PRE_OPEN, *pre, _PRE_CLOSE])
+                pre = []
+            flush()
+            pre.append(line)  # may exceed limit only if a single line is longer than limit
+        if pre:
+            buf.extend([_PRE_OPEN, *pre, _PRE_CLOSE])
+    flush()
+    return chunks
+
+
+def render_telegram_chunks(markdown: str, limit: int = 4000) -> list[str]:
+    """Convert the briefing Markdown to Telegram HTML and split it into chunks of at most ``limit`` chars.
+
+    Sections (``## `` headers) are packed greedily; an oversized section is split on lines, and
+    ``
`` blocks are closed and reopened so every chunk is balanced HTML.
+    """
+    chunks: list[str] = []
+    cur = ""
+    for section in _split_sections(markdown):
+        units = _to_units(section)
+        if not units:
+            continue
+        html = _units_html(units)
+        candidate = html if not cur else f"{cur}\n\n{html}"
+        if len(candidate) <= limit:
+            cur = candidate
+            continue
+        if cur:
+            chunks.append(cur)
+            cur = ""
+        if len(html) <= limit:
+            cur = html
+            continue
+        parts = _pack_units(units, limit)
+        if parts:
+            chunks.extend(parts[:-1])
+            cur = parts[-1]
+    if cur:
+        chunks.append(cur)
+    return chunks
diff --git a/us2kr/store.py b/us2kr/store.py
new file mode 100644
index 0000000..cb1c339
--- /dev/null
+++ b/us2kr/store.py
@@ -0,0 +1,418 @@
+"""SQLite persistence: quote history, report idempotency, delivery receipts, raw collector payloads.
+
+Single-threaded use only (one connection, WAL mode). Dates are stored as ISO ``YYYY-MM-DD`` text so
+lexicographic ordering equals chronological ordering.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import sqlite3
+from dataclasses import dataclass
+from datetime import UTC, date, datetime
+from pathlib import Path
+from typing import Self
+
+from us2kr.models import BriefingResult, Quote
+from us2kr.symbols import POINT_CHANGE_GROUPS, display_name, group_of
+
+log = logging.getLogger(__name__)
+
+# Symbols for which history-based notes are generated (order = order of notes in the briefing).
+HISTORY_SYMBOLS: tuple[str, ...] = ("^GSPC", "^IXIC", "^SOX", "^VIX", "KRW=X", "^TNX", "NVDA", "EWY")
+HISTORY_SESSIONS = 10  # look back at most this many stored sessions
+MIN_STREAK_DAYS = 3
+MIN_SESSIONS_FOR_AVG = 5  # need at least this many prior sessions to judge "unusual" moves
+BIG_MOVE_RATIO = 2.0
+VIX_LEVELS: tuple[float, ...] = (20.0, 30.0)
+KRW_STEP = 50.0
+# Streak contiguity: two stored rows count as consecutive sessions only when the calendar gap is at most
+# a weekend plus one holiday (Fri -> Tue) AND the later row's prev_close matches the earlier row's close
+# (yfinance revisions can shift a close slightly, hence the relative tolerance).
+MAX_SESSION_GAP_DAYS = 4
+PREV_CLOSE_TOLERANCE = 0.002
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS quotes (
+    session_date TEXT NOT NULL,
+    symbol       TEXT NOT NULL,
+    close        REAL,
+    prev_close   REAL,
+    change_pct   REAL,
+    change_abs   REAL,
+    day_high     REAL,
+    day_low      REAL,
+    volume       REAL,
+    PRIMARY KEY (session_date, symbol)
+);
+CREATE TABLE IF NOT EXISTS reports (
+    id           INTEGER PRIMARY KEY,
+    krx_date     TEXT NOT NULL,
+    kind         TEXT NOT NULL,
+    session_date TEXT,
+    model_used   TEXT,
+    degraded     INTEGER NOT NULL DEFAULT 0,
+    markdown     TEXT,
+    result_json  TEXT,
+    created_at   TEXT,
+    UNIQUE (krx_date, kind)
+);
+CREATE TABLE IF NOT EXISTS deliveries (
+    id        INTEGER PRIMARY KEY,
+    report_id INTEGER NOT NULL,
+    channel   TEXT NOT NULL,
+    receipt   TEXT,
+    sent_at   TEXT
+);
+CREATE TABLE IF NOT EXISTS raw (
+    session_date TEXT NOT NULL,
+    name         TEXT NOT NULL,
+    payload_json TEXT,
+    created_at   TEXT,
+    PRIMARY KEY (session_date, name)
+);
+"""
+
+
+@dataclass(frozen=True)
+class _Row:
+    """One stored daily quote (subset of columns needed for history notes)."""
+
+    session_date: str
+    close: float | None
+    prev_close: float | None
+    change_pct: float | None
+    change_abs: float | None
+
+
+def _now_iso() -> str:
+    return datetime.now(UTC).isoformat(timespec="seconds")
+
+
+class Store:
+    """Thin sqlite3 wrapper. Creates parent directories and tables on construction."""
+
+    def __init__(self, db_path: Path) -> None:
+        self.db_path = Path(db_path)
+        self.db_path.parent.mkdir(parents=True, exist_ok=True)
+        self._conn = sqlite3.connect(str(self.db_path), timeout=30, isolation_level=None)
+        self._conn.row_factory = sqlite3.Row
+        self._conn.execute("PRAGMA journal_mode=WAL")
+        self._conn.execute("PRAGMA synchronous=NORMAL")
+        self._conn.executescript(_SCHEMA)
+        log.debug("store opened at %s", self.db_path)
+
+    # ------------------------------------------------------------------ quotes
+    def save_quotes(self, session_date: date, quotes: list[Quote]) -> None:
+        """Upsert the day's quotes keyed by (session_date, symbol)."""
+        rows = [
+            (
+                session_date.isoformat(),
+                q.symbol,
+                q.close,
+                q.prev_close,
+                q.change_pct,
+                q.change_abs,
+                q.day_high,
+                q.day_low,
+                q.volume,
+            )
+            for q in quotes
+        ]
+        with self._conn:
+            self._conn.executemany(
+                """
+                INSERT INTO quotes (session_date, symbol, close, prev_close, change_pct, change_abs,
+                                    day_high, day_low, volume)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+                ON CONFLICT(session_date, symbol) DO UPDATE SET
+                    close = excluded.close,
+                    prev_close = excluded.prev_close,
+                    change_pct = excluded.change_pct,
+                    change_abs = excluded.change_abs,
+                    day_high = excluded.day_high,
+                    day_low = excluded.day_low,
+                    volume = excluded.volume
+                """,
+                rows,
+            )
+        log.info("saved %d quotes for %s", len(rows), session_date)
+
+    def load_quotes(self, session_date: date) -> list[sqlite3.Row]:
+        """Return stored quote rows for one session (mainly for tests / debugging)."""
+        cur = self._conn.execute(
+            "SELECT * FROM quotes WHERE session_date = ? ORDER BY symbol", (session_date.isoformat(),)
+        )
+        return list(cur.fetchall())
+
+    def _history(self, symbol: str, before: date, n: int) -> list[_Row]:
+        """Up to ``n`` stored rows for ``symbol`` strictly before ``before``, oldest first."""
+        cur = self._conn.execute(
+            """
+            SELECT session_date, close, prev_close, change_pct, change_abs
+            FROM quotes WHERE symbol = ? AND session_date < ?
+            ORDER BY session_date DESC LIMIT ?
+            """,
+            (symbol, before.isoformat(), n),
+        )
+        rows = [
+            _Row(r["session_date"], r["close"], r["prev_close"], r["change_pct"], r["change_abs"])
+            for r in cur
+        ]
+        rows.reverse()
+        return rows
+
+    def history_notes(self, session_date: date, quotes: list[Quote]) -> list[str]:
+        """Korean one-liners derived from stored history + today's quotes.
+
+        Covers: N-day streaks (N>=3), today's move vs the 10-session mean absolute move,
+        VIX crossing 20/30 and USD/KRW crossing a multiple of 50 won. Returns [] without history.
+        """
+        today = {q.symbol: q for q in quotes}
+        notes: list[str] = []
+        for sym in HISTORY_SYMBOLS:
+            q = today.get(sym)
+            if q is None or q.close is None:
+                continue
+            hist = self._history(sym, session_date, HISTORY_SESSIONS)
+            if not hist:
+                continue
+            name = display_name(sym)
+            points = (q.group or group_of(sym)) in POINT_CHANGE_GROUPS
+            today_delta = _delta(q.change_pct, q.change_abs, q.close, q.prev_close, points)
+            deltas = [_delta(r.change_pct, r.change_abs, r.close, r.prev_close, points) for r in hist]
+
+            # Streaks may only count sessions forming an unbroken chain ending today; a hole in the table
+            # (daemon down past the catch-up window, prices collector failed) must not read as "N일 연속".
+            chain = _contiguous_tail(hist, session_date, q.prev_close)
+            chain_deltas = [_delta(r.change_pct, r.change_abs, r.close, r.prev_close, points) for r in chain]
+            note = _streak_note(name, chain_deltas + [today_delta])
+            if note:
+                notes.append(note)
+            note = _big_move_note(name, deltas, today_delta, points)  # a sample mean: holes are harmless
+            if note:
+                notes.append(note)
+
+            # Level crossings must agree with the rendered table, which is built from q.prev_close (the
+            # collector's directly preceding bar). Stored history is only a fallback: it may be a still-live
+            # snapshot for 24h instruments (KRW=X) or 2+ sessions old after a missed run.
+            prev_close = q.prev_close if q.prev_close is not None else hist[-1].close
+            if prev_close is None:
+                continue
+            if sym == "^VIX":
+                notes.extend(_level_cross_notes(name, prev_close, q.close, list(VIX_LEVELS), 1))
+            elif sym == "KRW=X":
+                notes.extend(
+                    _level_cross_notes(name, prev_close, q.close, _krw_levels(prev_close, q.close), 0, "원")
+                )
+        return notes
+
+    # ----------------------------------------------------------------- reports
+    def has_report(self, krx_date: date, kind: str = "briefing") -> bool:
+        cur = self._conn.execute(
+            "SELECT 1 FROM reports WHERE krx_date = ? AND kind = ? LIMIT 1", (krx_date.isoformat(), kind)
+        )
+        return cur.fetchone() is not None
+
+    def save_report(self, result: BriefingResult, kind: str = "briefing") -> int:
+        """Upsert the briefing on (krx_date, kind); return the report id."""
+        krx = result.payload.krx_date.isoformat()
+        with self._conn:
+            self._conn.execute(
+                """
+                INSERT INTO reports (krx_date, kind, session_date, model_used, degraded, markdown,
+                                     result_json, created_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                ON CONFLICT(krx_date, kind) DO UPDATE SET
+                    session_date = excluded.session_date,
+                    model_used = excluded.model_used,
+                    degraded = excluded.degraded,
+                    markdown = excluded.markdown,
+                    result_json = excluded.result_json,
+                    created_at = excluded.created_at
+                """,
+                (
+                    krx,
+                    kind,
+                    result.payload.session_date.isoformat(),
+                    result.model_used,
+                    int(result.degraded),
+                    result.markdown,
+                    result.model_dump_json(),
+                    _now_iso(),
+                ),
+            )
+            row = self._conn.execute(
+                "SELECT id FROM reports WHERE krx_date = ? AND kind = ?", (krx, kind)
+            ).fetchone()
+        report_id = int(row["id"])
+        log.info("saved report id=%d krx_date=%s kind=%s degraded=%s", report_id, krx, kind, result.degraded)
+        return report_id
+
+    def mark_sent(self, report_id: int, channel: str, receipt: str) -> None:
+        with self._conn:
+            self._conn.execute(
+                "INSERT INTO deliveries (report_id, channel, receipt, sent_at) VALUES (?, ?, ?, ?)",
+                (report_id, channel, receipt, _now_iso()),
+            )
+
+    def deliveries_for(self, report_id: int) -> list[sqlite3.Row]:
+        cur = self._conn.execute("SELECT * FROM deliveries WHERE report_id = ? ORDER BY id", (report_id,))
+        return list(cur.fetchall())
+
+    # --------------------------------------------------------------------- raw
+    def save_raw(self, session_date: date, name: str, payload_json: str) -> None:
+        with self._conn:
+            self._conn.execute(
+                """
+                INSERT INTO raw (session_date, name, payload_json, created_at) VALUES (?, ?, ?, ?)
+                ON CONFLICT(session_date, name) DO UPDATE SET
+                    payload_json = excluded.payload_json, created_at = excluded.created_at
+                """,
+                (session_date.isoformat(), name, payload_json, _now_iso()),
+            )
+
+    def load_raw(self, session_date: date, name: str) -> str | None:
+        row = self._conn.execute(
+            "SELECT payload_json FROM raw WHERE session_date = ? AND name = ?",
+            (session_date.isoformat(), name),
+        ).fetchone()
+        return None if row is None else row["payload_json"]
+
+    # ------------------------------------------------------------------- misc
+    def close(self) -> None:
+        try:
+            self._conn.close()
+        except sqlite3.Error:  # pragma: no cover - defensive
+            log.debug("store close failed", exc_info=True)
+
+    def __enter__(self) -> Self:
+        return self
+
+    def __exit__(self, *exc: object) -> None:
+        self.close()
+
+
+# ----------------------------------------------------------------------- helpers
+
+
+def _delta(
+    change_pct: float | None,
+    change_abs: float | None,
+    close: float | None,
+    prev_close: float | None,
+    points: bool,
+) -> float | None:
+    """Daily move in the unit relevant for the symbol: yield points for rates, percent otherwise."""
+    if points:
+        if change_abs is not None:
+            return change_abs
+        if close is not None and prev_close is not None:
+            return close - prev_close
+        return None
+    if change_pct is not None:
+        return change_pct
+    if close is not None and prev_close:
+        return (close / prev_close - 1.0) * 100.0
+    return None
+
+
+def _is_predecessor(older: _Row, newer_date: str, newer_prev_close: float | None) -> bool:
+    """True when ``older`` looks like the trading session immediately before ``newer_date``."""
+    gap = (date.fromisoformat(newer_date) - date.fromisoformat(older.session_date)).days
+    if gap <= 0 or gap > MAX_SESSION_GAP_DAYS:
+        return False
+    if older.close and newer_prev_close is not None:
+        return abs(newer_prev_close / older.close - 1.0) <= PREV_CLOSE_TOLERANCE
+    return True  # no prices to cross-check; the calendar gap is plausible
+
+
+def _contiguous_tail(hist: list[_Row], session_date: date, today_prev_close: float | None) -> list[_Row]:
+    """Longest suffix of ``hist`` (oldest first) forming an unbroken session chain ending at today."""
+    tail: list[_Row] = []
+    newer_date, newer_prev = session_date.isoformat(), today_prev_close
+    for r in reversed(hist):
+        if not _is_predecessor(r, newer_date, newer_prev):
+            break
+        tail.append(r)
+        newer_date, newer_prev = r.session_date, r.prev_close
+    tail.reverse()
+    return tail
+
+
+def _streak_note(name: str, deltas: list[float | None]) -> str | None:
+    """'{name} N일 연속 상승/하락' when the last N>=3 deltas share a sign (last element = today)."""
+    if not deltas or deltas[-1] is None or deltas[-1] == 0:
+        return None
+    up = deltas[-1] > 0
+    n = 0
+    for d in reversed(deltas):
+        if d is None or d == 0 or (d > 0) != up:
+            break
+        n += 1
+    if n < MIN_STREAK_DAYS:
+        return None
+    return f"{name} {n}일 연속 {'상승' if up else '하락'}"
+
+
+def _big_move_note(
+    name: str, hist_deltas: list[float | None], today: float | None, points: bool
+) -> str | None:
+    """Flag today's move when it exceeds BIG_MOVE_RATIO x the mean absolute move of stored sessions."""
+    moves = [abs(d) for d in hist_deltas if d is not None]
+    if today is None or len(moves) < MIN_SESSIONS_FOR_AVG:
+        return None
+    mean = sum(moves) / len(moves)
+    if mean <= 0 or abs(today) <= BIG_MOVE_RATIO * mean:
+        return None
+    ratio = abs(today) / mean
+    if points:
+        return (
+            f"{name} 오늘 {today * 100:+.1f}bp — 최근 {len(moves)}일 평균 변동폭({mean * 100:.1f}bp)의 "
+            f"{ratio:.1f}배, 평소보다 큰 변동"
+        )
+    return f"{name} 오늘 {today:+.2f}% — 최근 {len(moves)}일 평균 변동폭({mean:.2f}%)의 {ratio:.1f}배, 평소보다 큰 변동"
+
+
+def _krw_levels(prev: float, close: float) -> list[float]:
+    """Candidate multiples of KRW_STEP spanning [prev, close]; crossing is decided by the caller."""
+    lo, hi = sorted((prev, close))
+    first = math.floor(lo / KRW_STEP) * KRW_STEP
+    last = math.ceil(hi / KRW_STEP) * KRW_STEP
+    return [first + i * KRW_STEP for i in range(round((last - first) / KRW_STEP) + 1)]
+
+
+def _fmt_pair(a: float, b: float, decimals: int) -> tuple[str, str]:
+    """Format two values, adding precision (up to 4 dp) until the strings differ.
+
+    Otherwise a 1399.99 -> 1400.0 break would read "1,400 → 1,400", contradicting itself.
+    """
+    sa, sb = f"{a:,.{decimals}f}", f"{b:,.{decimals}f}"
+    for d in range(decimals + 1, 5):
+        if sa != sb:
+            break
+        sa, sb = f"{a:,.{d}f}", f"{b:,.{d}f}"
+    return sa, sb
+
+
+def _level_cross_notes(
+    name: str, prev: float, close: float, levels: list[float], decimals: int, unit: str = ""
+) -> list[str]:
+    """Note for a level strictly crossed between prev and close.
+
+    Up: prev < L < close; down: prev > L > close.
+
+    Touching a level exactly (landing on it or leaving from it) is not a break.
+    """
+    notes: list[str] = []
+    crossed = [lvl for lvl in levels if (prev < lvl < close) or (prev > lvl > close)]
+    if not crossed:
+        return notes
+    # report only the level nearest to today's close (avoid spam if several were crossed)
+    lvl = min(crossed, key=lambda x: abs(x - close))
+    direction = "상향 돌파" if close > prev else "하향 이탈"
+    lvl_txt = f"{lvl:,.0f}{unit}"
+    prev_txt, close_txt = _fmt_pair(prev, close, decimals)
+    notes.append(f"{name} {lvl_txt} {direction} ({prev_txt} → {close_txt})")
+    return notes
diff --git a/us2kr/summarize.py b/us2kr/summarize.py
new file mode 100644
index 0000000..c724dda
--- /dev/null
+++ b/us2kr/summarize.py
@@ -0,0 +1,30 @@
+"""Glue between the pipeline and the LLM layer."""
+
+from __future__ import annotations
+
+import logging
+
+from us2kr.config import Settings
+from us2kr.llm.base import LLMProvider
+from us2kr.llm.minimax import MiniMaxProvider
+from us2kr.models import MarketPayload, Report
+
+log = logging.getLogger(__name__)
+
+
+def build_provider(settings: Settings) -> LLMProvider:
+    """Construct the configured LLM provider (MiniMax only for now)."""
+    return MiniMaxProvider(settings)
+
+
+def summarize_payload(payload: MarketPayload, provider: LLMProvider) -> tuple[Report, str]:
+    """Summarise via the provider and guarantee `report.data_gaps` includes every payload gap."""
+    report, model_used = provider.summarize(payload)
+    merged: list[str] = []
+    for gap in [*payload.data_gaps, *report.data_gaps]:
+        if gap and gap not in merged:
+            merged.append(gap)
+    if merged != list(report.data_gaps):
+        report = report.model_copy(update={"data_gaps": merged})
+    log.info("summary produced by %s (%d data gaps)", model_used, len(merged))
+    return report, model_used
diff --git a/us2kr/symbols.py b/us2kr/symbols.py
new file mode 100644
index 0000000..e1773ec
--- /dev/null
+++ b/us2kr/symbols.py
@@ -0,0 +1,111 @@
+"""Symbol universe grouped by role. Keys are Yahoo Finance tickers.
+
+Group names are used by the renderer/prompt to organise the briefing, so keep them stable.
+"""
+
+from __future__ import annotations
+
+# group -> {ticker: display name (Korean)}
+SYMBOL_GROUPS: dict[str, dict[str, str]] = {
+    "indices": {
+        "^GSPC": "S&P 500",
+        "^DJI": "다우존스",
+        "^IXIC": "나스닥 종합",
+        "^RUT": "러셀 2000",
+        "^SOX": "필라델피아 반도체(SOX)",
+        "^VIX": "VIX",
+    },
+    "futures": {
+        "ES=F": "S&P 500 선물",
+        "NQ=F": "나스닥 100 선물",
+        "YM=F": "다우 선물",
+    },
+    "rates": {
+        "^IRX": "미 3개월물",
+        "^FVX": "미 5년물",
+        "^TNX": "미 10년물",
+        "^TYX": "미 30년물",
+    },
+    "fx": {
+        "KRW=X": "달러/원",
+        "DX-Y.NYB": "달러인덱스(DXY)",
+        "JPY=X": "달러/엔",
+    },
+    "commodities": {
+        "CL=F": "WTI 원유",
+        "GC=F": "금",
+        "HG=F": "구리",
+        "BTC-USD": "비트코인",
+    },
+    "sectors": {
+        "XLK": "기술(XLK)",
+        "XLF": "금융(XLF)",
+        "XLE": "에너지(XLE)",
+        "XLV": "헬스케어(XLV)",
+        "XLI": "산업재(XLI)",
+        "SMH": "반도체 ETF(SMH)",
+    },
+    "korea_linked": {
+        "EWY": "MSCI Korea ETF(EWY)",
+        "CPNG": "쿠팡",
+        "KB": "KB금융 ADR",
+        "SHG": "신한지주 ADR",
+        "PKX": "POSCO홀딩스 ADR",
+        "KEP": "한국전력 ADR",
+        "LPL": "LG디스플레이 ADR",
+        "SKM": "SK텔레콤 ADR",
+    },
+    "semis": {
+        "NVDA": "엔비디아",
+        "TSM": "TSMC",
+        "MU": "마이크론",
+        "AMD": "AMD",
+        "AVGO": "브로드컴",
+        "ASML": "ASML",
+    },
+    "megacap": {
+        "AAPL": "애플",
+        "MSFT": "마이크로소프트",
+        "GOOGL": "알파벳",
+        "AMZN": "아마존",
+        "META": "메타",
+        "TSLA": "테슬라",
+    },
+}
+
+# Groups whose "change" is expressed in yield points / index points rather than percent
+POINT_CHANGE_GROUPS = {"rates"}
+
+GROUP_TITLES_KO: dict[str, str] = {
+    "indices": "주요 지수",
+    "futures": "야간 선물",
+    "rates": "미국 국채 금리",
+    "fx": "환율·달러",
+    "commodities": "원자재·크립토",
+    "sectors": "섹터 ETF",
+    "korea_linked": "한국 연관(EWY·ADR)",
+    "semis": "반도체",
+    "megacap": "빅테크",
+}
+
+
+def all_tickers() -> list[str]:
+    seen: dict[str, None] = {}
+    for grp in SYMBOL_GROUPS.values():
+        for t in grp:
+            seen.setdefault(t, None)
+    return list(seen)
+
+
+def group_of(ticker: str) -> str | None:
+    for g, members in SYMBOL_GROUPS.items():
+        if ticker in members:
+            return g
+    return None
+
+
+def display_name(ticker: str) -> str:
+    for members in SYMBOL_GROUPS.values():
+        if ticker in members:
+            return members[ticker]
+    return ticker