Cloudwiki

CloudStock

# 주식 레이더 대시보드 — 기획 문서

> 개인 트레이더 1~2인용 주식 레이더 / Cloudflare Workers 전용 아키텍처
> 마지막 업데이트: 2026-05-22

---

## 1. 프로젝트 개요

### 목적
가족(전업에 가까운 다년차 개인 트레이더) 1명이 매일 아침 시장 상황을 한눈에 파악할 수 있는 개인 대시보드. 모든 데이터 수집·요약·표시는 자동화되며, 사용자는 아침에 접속해서 그날의 시장 그림을 확인하는 것이 주된 사용 패턴.

### 운영 모델
- 사용자: 가족 1명 (잠재적으로 본인 포함 최대 2명)
- 비용 구조: 월 $10~15 내외의 Workers + AI 비용
- 데이터 신선도: 미국주식은 매일 아침 7시 기준, 한국주식은 장중 매시간

### 비목적 (Non-Goals)
- 실시간 자동매매 / 주문 실행 — 명시적으로 제외
- 다수 사용자 서비스 — 가족 한정
- 모바일 네이티브 앱 — PWA로 충분
- 차트 분석 도구 — TradingView 위젯에 위임

---

## 2. 사용자 및 디바이스 프로파일

### 개발자 본인
- 90% 이상의 코드 작성을 Claude Code 및 에이전트에 의존
- 본인의 학습 곡선은 고려 대상 아님
- 단, 익숙한 스택 사용 시 에이전트와의 소통이 원활하다는 점은 반영
- React/Vue 등 SPA 프레임워크 경험 없음
- Bootstrap 선호, Tailwind 경험 없음
- 운영 중 발생하는 수정은 웹에서 Cloudflare Dashboard 직접 접근 또는 GitHub 웹으로 대응 가능, Claude Code 웹 에이전트 상시 동원 가능.

### 최종 사용자
- 도메인 지식: 다년차 개인 트레이더, 섹터 로테이션·반도체 사이클 등을 직관적으로 이해
- 기술 친숙도: 비개발자, PC 사용에는 익숙
- HTS에 익숙하므로 정보 밀도가 높은 화면에 거부감 없음

### 디바이스
| 디바이스 | 용도 |
| ---------- | ------ |
| 갤럭시 S Ultra (펜 미사용) | 모바일 주력 |
| 데스크탑 | 주력1 |
| 젠북 듀오 하단 스크린 | 주력2 |

### 브라우저
- **Chrome 고정** (Android Chrome + Desktop Chrome)
- 삼성 인터넷은 명시적으로 사용 안 함 (개발자가 크롬 바로가기 제공)
- 호환성 매트릭스 단순화 → 모든 Blink 기반 동일 동작 가정
- PWA 설치는 선택사항

---

## 3. 기능 요구사항

### 미국주식 (Phase 1~3)

**주요 지수 카드 (8개)**
나스닥, S&P 500, 다우존스, 필라델피아반도체, 금, 유가, 달러/원 환율, 미국채 10년물 금리. TradingView 무료 위젯 임베드. **화면비(aspect-ratio) 기준으로 위젯을 전환**한다 — 가로 화면은 Symbol Overview(1Y 차트), 세로 화면(모바일, `max-aspect-ratio: 1/1`)은 컴팩트한 Single Quote 위젯. 클라이언트(`src/scripts/indexWidgets.ts`)에서 `matchMedia` 로 감지하며 회전·리사이즈로 임계점을 넘으면 즉시 다시 그린다. (감지 기준은 기기 종류·뷰포트 너비가 아니라 순수 화면비.)

**AI 시장 흐름 요약**
3-bullet 형식. 전반적 분위기 / 섹터별 흐름 / 거래량·심리. 40~60대 개인 트레이더가 읽는 경제 신문 칼럼 말투, 경어, 각 항목 2~3문장.

**섹터별 히트맵**
TradingView Stock Heatmap 위젯. 시총 기준 블록 크기, 등락률 기준 색상.

**미국주식 뉴스 헤드라인**
5개. Finnhub 무료 티어 1차, Perplexity 보조. 헤드라인 + 출처 + 발행시각 + 원문 링크.

**섹터별 탭**
미국 시총 Top 10, AI, 조선해양, 시스템반도체, 메모리반도체, 네트워크 및 광통신, 로봇, 전력인프라, 자율주행 및 전기차. 각 섹터별 종목 리스트는 D1에 수동 큐레이션.

**상승/하락 특징주**
각 Top 10. 종목명 + 등락률 + 어제의 핵심 이슈 한줄 요약 (AI 생성).
### 한국주식 (Phase 4 — 미국 완성 후 착수)
기획 별도 진행. 데이터 소스는 KIS Open API 확정.

---

## 데이터 소스 매트릭스

| 카테고리 | 소스 | 비용 | 갱신 주기 | 비고 |
|---------|------|------|----------|------|
| 미국 지수 표시 | TradingView 위젯 | $0 | 위젯 자체 | 별도 API 호출 없음 |
| 미국 종목 EOD | Financial Modeling Prep | 무료 250 req/일 시작, 부족 시 Starter ($22/월 연간결제) | 매일 07:00 KST | 배치 quote 다중 ticker 지원 |
| 미국 일반 뉴스 | Finnhub 무료 | $0 | 매일 07:00 KST | `/news?category=general` |
| 종목별 뉴스 | Finnhub 무료 | $0 | 매일 07:00 KST × Top 20 mover | `/company-news`. 직렬 + 100ms gap (60 req/min 한도 내) |
| AI 요약 (1차) | Google Gemini API 프리티어 | $0 (한도 내) | 매일 07:00 KST | `GEMINI_MODEL` env로 모델 변경 가능. 시장 3-bullet + mover 한줄요약 |
| AI 요약 (폴백) | Workers AI Gemma (`@cf/google/gemma-4-26b-a4b-it` 기본) | $0.10/$0.30 per M | Gemini 실패/키 미설정 시 | `GEMMA_MODEL` env로 모델 변경 가능 |
| 범용 웹 서치 | Perplexity Search API | $5/1,000 req | (Phase 3에서는 미사용, 향후 옵션) | 종목별 Finnhub 뉴스로 컨텍스트가 충분하다고 판단해 제외 |
| 한국 주요 종목 시세 | KIS Open API | $0 (계좌만) | 평일 9:00~15:30 매시간 | 섹터 인덱스 + 개별 종목 등락률. 실전 초당 20건, 토큰 24h |
| 한국 섹터 뉴스 | Perplexity Search API | $5/1,000 req (~$4.2/월) | 평일 9:00~15:30 매시간 | 섹터당 쿼리 1건. raw 검색결과, LLM 미경유. `country: KR`, `search_language_filter: ["ko"]`, `search_recency_filter: "day"` |

---

## 5. 기술 스택 최종 확정

```
┌─────────────────────────────────────────────────────┐
│ 프론트엔드                                            │
│ ├─ Astro (Islands Architecture, 정적 사이트 생성)    │
│ ├─ Bootstrap 5 (스타일링)                            │
│ ├─ Bootstrap Icons + MDI (웹폰트)                    │
│ ├─ TradingView 위젯 (지수 카드, 히트맵)             │
│ └─ 인터랙션: 인라인 JS + Bootstrap JS                │
├─────────────────────────────────────────────────────┤
│ 백엔드                                               │
│ ├─ Hono (Cloudflare Workers API 서버 + 정적 에셋)   │
│ ├─ Workers Static Assets ([assets] 바인딩)          │
│ ├─ 배치 파이프라인 (Cron Trigger + 모듈 함수)       │
│ │  └─ step 체크포인트는 D1 (Durable Object 없음)    │
│ └─ 인증 미들웨어 (JWT 세션 + Turnstile)             │
├─────────────────────────────────────────────────────┤
│ 데이터 저장                                          │
│ ├─ D1 (스냅샷·뉴스·요약·step_cache)                 │
│ └─ R2 (시장 요약 TTS 음성 WAV 캐시)                 │
├─────────────────────────────────────────────────────┤
│ AI                                                   │
│ ├─ Workers AI 바인딩 (env.AI.run)                    │
│ ├─ Gemma 4 26B A4B (시장·mover 요약 + 뉴스 번역)    │
│ ├─ GLM 4.7 flash (개별 종목 뉴스 헤드라인 번역)     │
│ ├─ Gemini 3.1 Flash TTS (시장 요약 음성)            │
│ └─ Perplexity Search API (Phase 4 후보, 현재 미사용)│
├─────────────────────────────────────────────────────┤
│ 외부 데이터                                          │
│ ├─ FMP (미국 EOD 종목 시세)                          │
│ ├─ Finnhub (미국 뉴스)                              │
│ └─ KIS Open API (한국주식, Phase 4)                  │
├─────────────────────────────────────────────────────┤
│ 보안                                                 │
│ ├─ 로그인 페이지 + Cloudflare Turnstile (1차)        │
│ ├─ JWT 세션 쿠키 HS256 (cs_session)                 │
│ └─ 동일 출처 검증 (쓰기 CSRF 방어)                  │
└─────────────────────────────────────────────────────┘
```

### 결정 근거 요약
- **Workers 단일 플랫폼**: 단순함과 일관성. 로컬 노트북은 개발 시에만 사용.
- **Astro**: 정적 사이트 생성이 본 프로젝트 패턴에 최적. HTML에 가까운 문법으로 React/Vue 경험 없이도 Claude Code와 협업 용이.
- **Bootstrap**: 익숙함이 결정 변수. 의미적 클래스 이름이 Claude Code 협업에 유리.
- **D1 단일 계층**: 사용자가 단일 리전(집)에 고정되어 KV의 글로벌 엣지 캐시 이점이 무효. eventual consistency 단점만 남음. (음성 캐시만 R2 사용.)
- **배치 = Cron + 모듈 함수 (Durable Object 없음)**: 종목 선정이 `pickMovers()`로 deterministic하고 모든 AI 작업이 "고정 입력 → 한국어 출력"이라 agentic 실행/DO가 불필요. 한때 Agents SDK DO로 구현했으나, ① DO 구현 워커는 프리뷰 URL이 발급되지 않아 PR 미리보기가 막히고 ② 실제로 쓰는 건 step 체크포인트뿐이라, 일반 함수(`worker/batch/stockRadar.ts`) + D1 `step_cache` 테이블로 전환. Cron 자동 재시도 없음은 step_cache 재시작(같은 run_id로 완료 step 건너뛰기)으로 보완.
- **Workers AI 단일 경로 (Gemma 4 + GLM)**: 메인 요약은 Gemma 4 26B A4B(MoE 구조로 4B 추론 비용에 26B 품질, 시장 요약·mover 한줄 요약·대시보드 일반 뉴스 번역 겸용). 개별 종목 관련 뉴스 헤드라인 번역만 GLM 4.7 flash로 분리. 모두 Workers AI 네이티브라 추가 외부 호출 없음. (기획 초안의 Kimi K2.6은 미채택.)
- **Gemini 3.1 Flash TTS**: 시장 요약 음성 듣기 전용(텍스트 요약엔 미사용). cron이 1회 합성해 R2에 캐시하고 `/api/tts`가 서빙.
- **Workers Static Assets**: Pages 없이 Worker 단독으로 정적 에셋 서빙. `[assets] directory = "./dist"` 설정 하나로 Astro 빌드 결과를 통합 배포. 도메인도 `stock.vialinks.xyz` 단일 호스트로 단순화.
- **Perplexity Search API**: Phase 4 한국 섹터별 웹 서치 후보. 현재 미사용(`PERPLEXITY_API_KEY`만 예약).

---
## 6. 시스템 아키텍처

### 도메인 구조
```
stock.vialinks.xyz        → Hono Worker 단독 (정적 에셋 + API 통합)
stock.vialinks.xyz/api/*  → Hono 라우터 (JWT 세션 인증 적용)
stock.vialinks.xyz/*      → Workers 정적 에셋 서빙 (Astro 빌드 결과)
*.workers.dev             → PR 버전 프리뷰 URL (호스트 가드로 읽기전용)
```

### 데이터 흐름

```
┌─────────────────────────────────────────────────────────────┐
│ Chrome (S Ultra / 노트북 / 젠북 듀오)                        │
│   PWA로 설치된 Astro 사이트                                  │
└────────────────────────┬────────────────────────────────────┘
                         │ HTTPS
                         ▼
┌─────────────────────────────────────────────────────────────┐
│ Cloudflare Edge                                              │
│  1. 로그인 페이지 + Turnstile (1차), JWT 세션 쿠키 검증     │
│  2. Workers Static Assets: /* → Astro 빌드 결과 서빙        │
│  3. /api/* → Hono Worker (run_worker_first)                 │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│ Hono Worker (stock.vialinks.xyz)                             │
│  - 미들웨어: JWT 세션 쿠키(cs_session) 검증                  │
│  - 프리뷰 가드: *.workers.dev 면 D1/R2 읽기전용 + push 스킵 │
│  - GET /api/snapshot/:market → D1 SELECT 최신 스냅샷         │
│  - GET /api/news            → D1 SELECT 최근 뉴스            │
│  - GET /api/summary/:market → D1 SELECT AI 요약             │
│  - GET /api/stock/:ticker   → 종목 상세(필요 시 뉴스 보강)  │
│  - GET /api/tts             → R2 음성 캐시 서빙(미스 시 합성)│
│  - /*                       → env.ASSETS.fetch() fallback   │
└────────────────────────┬────────────────────────────────────┘
                         │ D1 / R2 바인딩
                         ▼
        ┌────────────────┐      ┌────────────────┐
        │ D1 (SQLite)    │      │ R2             │
        │ snapshots      │      │ TTS WAV 캐시   │
        │ news           │      └────────▲───────┘
        │ ai_summary     │               │
        │ sectors        │               │
        │ tickers        │               │
        │ token_cache    │               │
        │ push_subscr... │               │
        │ step_cache     │               │
        └────────▲───────┘               │
                 │ WRITE                  │ WRITE
                 │                        │
┌────────────────┴────────────────────────┴───────────────────┐
│ 배치 모듈 (Cron Trigger, worker/batch/stockRadar.ts)         │
│  cron 또는 /api/admin/trigger 가 함수를 직접 호출            │
│                                                              │
│ 매 평일 07:00 KST (cron: "0 22 * * MON-FRI" UTC)             │
│  fetchUSMarket(env, runId?):                                 │
│    step('tracked')   → D1 추적 티커 목록                     │
│    step('fmp')       → FMP /stable/batch-quote               │
│    step('news')      → Finnhub general news → 번역(Gemma)    │
│    step('persist')   → D1 snapshots upsert → Web Push        │
│    step('company_news') → Finnhub company-news (mover별)     │
│    step('summarize_market') → env.AI.run(GEMMA_MODEL)        │
│    step('tts_market')→ Gemini TTS → R2 put                   │
│    step('summarize_movers') → mover 한줄 요약 → D1           │
│                                                              │
│ fetchKRMarket(): [Phase 4에서 구현]                          │
│                                                              │
│ step 체크포인트: D1 step_cache(run_id, step_name) 14일 보관  │
└──────────────────────────────────────────────────────────────┘
```

### 핵심 설계 원칙
- **실행 상태 vs 공유 데이터 분리**: step 체크포인트는 D1 `step_cache` 테이블, 프론트엔드가 읽는 최종 결과는 D1 공유 테이블(snapshots/news/ai_summary). (과거엔 전자를 Durable Object 내장 SQLite에 뒀으나 DO 제거 후 D1로 일원화.)
- **재시도 가능성**: 외부 호출을 `step(env, runId, name, fn)`으로 감싸 체크포인트 보존. FMP가 성공하고 AI가 실패하면 같은 runId 재시작 시 FMP는 건너뜀. Cron 자동 재시도가 없으므로 수동 트리거(`/api/admin/trigger/*`)로 재실행.
- **읽기 경로 단순화**: 프론트엔드 API는 D1 SELECT 중심. (종목 상세·TTS는 캐시 미스 시에만 외부 호출/합성.)
- **프리뷰 격리**: 단일 워커가 prod 바인딩을 공유하므로, `*.workers.dev` 호스트 요청은 미들웨어가 D1·R2를 읽기전용 래퍼로 감싸고 배치 Web Push를 스킵해 미검토 PR 코드가 prod에 영향을 못 주게 한다. cron은 미들웨어를 거치지 않아 영향 없음.

---
## 7. 보안 설계 — JWT 세션 + Turnstile

### 위협 모델
- 가족 1~2인용 비공개 대시보드. 일반 인터넷에서는 접근 불가해야 한다.
- 위협: 인터넷 무차별 스캔, 의도된 침투 시도, KIS 토큰 또는 시세 데이터 노출, 로그인 페이지 브루트포스, CSRF
- 기존 IP 화이트리스트(WAF + Hono 미들웨어 2중) 는 IP 변경 시 비상 진입로가 없어 운영 부담 ⇒ **JWT 세션 쿠키 + 단일 비밀번호 로그인** 으로 전환

### 1차 방어: 로그인 페이지 + Turnstile
`/login` 페이지에서 비밀번호 + Cloudflare Turnstile 위젯을 함께 제출. 서버는 비밀번호 비교 **이전에** Turnstile 토큰을 `https://challenges.cloudflare.com/turnstile/v0/siteverify` 로 검증해 봇/자동화 브루트포스를 차단한다.

- 사이트키: `0x4AAAAAADUwrG2i3-UBqcO8` (공개, `login.astro` 하드코딩)
- 시크릿: `TURNSTILE_SECRET_KEY` (wrangler secret)
- 검증 실패 시 비밀번호 비교 자체를 건너뜀 → 비밀번호 oracle 보호

### 2차 방어: JWT 세션 쿠키 (HS256)
- 비밀번호 검증 통과 시 HMAC-SHA256 JWT 발급 → `Set-Cookie: cs_session=...`
- 쿠키 속성: `HttpOnly` (XSS-탈취 방지) + `Secure` (HTTPS 한정) + `SameSite=Lax` + `Max-Age=604800` (7일)
- 모든 `/api/*` 요청은 `jwtAuth` 미들웨어에서 쿠키 검증. 실패 시 401, 프론트는 401 감지하면 `/login` 으로 리다이렉트
- 인증 불필요 엔드포인트: `/api/health`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`
- 미보호 페이지 입장 시: `Base.astro` inline 스크립트가 `/api/auth/me` 핑 → 401 이면 즉시 `/login` 으로 이동

### 단일 시크릿 정책
`AUTH_PASSWORD` 하나가 **로그인 비밀번호 + JWT HS256 서명 키** 역할을 겸한다.

- 운영 관리 부담 최소화 (시크릿 1개)
- 비밀번호 회전 = 서명 키 회전 ⇒ 전 세션 자동 무효 (재로그인 강제)
- 비밀번호는 충분히 긴 무작위 문자열(≥ 24자) 권장 — HMAC 키 엔트로피 확보 목적

### IP 화이트리스트 자동 통과 (선택)
신뢰 단말의 고정 IP 를 등록해 로그인 없이 대시보드를 쓰기 위한 **선택적 옵션**. `AUTH_IP_WHITELIST`(secret, 쉼표/공백 구분 IP 목록) 에 포함된 요청은 비밀번호·Turnstile 없이 `/api/*` 인증을 자동 통과한다.

- 신뢰 출처: Cloudflare 엣지가 세팅하는 `CF-Connecting-IP` 만 사용 (클라이언트 위조 불가). `X-Forwarded-For` 등 클라 헤더는 쓰지 않는다.
- 적용 지점: `jwtAuth` 미들웨어 + `/api/auth/me` (후자가 통과해야 `Base.astro` 가 `/login` 으로 리다이렉트하지 않음). 구현은 `worker/lib/ipWhitelist.ts`.
- state-changing 라우트의 동일 출처(CSRF) 검증은 화이트리스트와 무관하게 유지된다 — 자동 통과는 인증 한정.
- 하위 호환: 미설정이면 빈 집합 → 자동 통과 없이 기존 비밀번호+Turnstile 로그인 그대로. 과거의 "IP 변경 시 비상 진입로 없음" 문제는 재발하지 않는다 (비화이트리스트 IP 는 로그인으로 진입 가능).
- 노출 방지: 공개 레포에 IP 가 커밋되지 않도록 `wrangler.toml` 이 아닌 `wrangler secret put AUTH_IP_WHITELIST` 로 주입.
- ⚠️ 공유 IP(CGNAT·공용 와이파이)는 같은 IP 의 타인도 통과 → 고정 단독 IP 만 등록.
- 과거 WAF Custom Rule 기반 `allowed_ips` IP List(하드 게이트 2중 화이트리스트) 와는 별개의 앱 레벨 보조 기능이다.

### CSRF / 부가 방어
- 상태 변경 POST (`/api/auth/login`, `/api/auth/logout`, `/api/admin/trigger/us`) 는 공용 `checkSameOrigin` 헬퍼로 `Origin` 헤더의 정규화 origin (scheme+host+port) 과 요청 URL origin 을 비교. 불일치 시 403.
- 비밀번호 비교는 `timingSafeEqual` constant-time.
- HttpOnly 쿠키이므로 JS 가 쿠키 값을 직접 읽을 수 없다 — 인증 상태 확인은 `/api/auth/me` 핑으로 우회.

### 비밀번호 분실 / 시크릿 회전 시나리오
1. `wrangler secret put AUTH_PASSWORD` 로 새 비밀번호 주입 → 자동 재배포 후 전 세션 즉시 만료
2. 본인 단말에서 새 비밀번호로 로그인
3. (선택) 동시에 Turnstile 사이트키 도 회전하려면 Cloudflare Dashboard → Turnstile 에서 시크릿 재발급 후 `wrangler secret put TURNSTILE_SECRET_KEY`

별도 백도어(Cloudflare Access 등) 는 추가하지 않는다 — 공격 표면 최소화 원칙.

---

## 8. 스토리지 설계 — D1 (+ R2 음성 캐시)

### 테이블 스키마

```sql
-- 최신 스냅샷 (시장별 일별)
CREATE TABLE snapshots (
  market TEXT NOT NULL,             -- 'us' | 'kr'
  snapshot_date TEXT NOT NULL,      -- 'YYYY-MM-DD'
  payload JSON NOT NULL,            -- 전체 스냅샷 JSON
  updated_at INTEGER NOT NULL,      -- Unix timestamp
  PRIMARY KEY (market, snapshot_date)
);
CREATE INDEX idx_snapshots_market_date ON snapshots(market, snapshot_date DESC);

-- 뉴스 헤드라인
CREATE TABLE news (
  id TEXT PRIMARY KEY,              -- 소스 ID 또는 URL 해시
  market TEXT NOT NULL,             -- 'us' | 'kr'
  published_at INTEGER NOT NULL,    -- Unix timestamp
  title TEXT NOT NULL,
  url TEXT NOT NULL,
  source TEXT,                      -- 'finnhub' 등
  related_tickers TEXT              -- JSON array: ["NVDA","AMD"]
);
CREATE INDEX idx_news_market_published ON news(market, published_at DESC);

-- AI 요약 (시장요약, 종목 한줄요약 등)
CREATE TABLE ai_summary (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  market TEXT NOT NULL,
  kind TEXT NOT NULL,               -- 'market_overview' | 'mover_oneliner'
  target_date TEXT NOT NULL,        -- 'YYYY-MM-DD'
  ticker TEXT,                      -- 종목 한줄요약일 때만
  payload JSON NOT NULL,            -- 요약 본문 + 출처 URL
  model TEXT NOT NULL,              -- 실제 응답 모델 슬러그 (GEMMA_MODEL / GLM_MODEL)
  created_at INTEGER NOT NULL
);
-- 0003: (market, kind, target_date, ticker) 유니크 (멱등 upsert)
CREATE INDEX idx_summary_date_kind ON ai_summary(target_date, kind);

-- 섹터별 종목 큐레이션
CREATE TABLE sectors (
  sector_name TEXT NOT NULL,        -- 'ai' | 'shipbuilding' | 'system_semi' | ...
  ticker TEXT NOT NULL,
  market TEXT NOT NULL,             -- 'us' | 'kr'
  display_name TEXT NOT NULL,       -- 한글 또는 영문 표시명
  rank INTEGER,                     -- 섹터 내 표시 순서
  PRIMARY KEY (sector_name, ticker)
);

-- 전체 종목 메타데이터
CREATE TABLE tickers (
  ticker TEXT NOT NULL,
  market TEXT NOT NULL,             -- 'us' | 'kr'
  name_en TEXT,
  name_ko TEXT,
  PRIMARY KEY (ticker, market)
);

-- 토큰/시크릿 캐시 (KIS access_token 등, Phase 4)
CREATE TABLE token_cache (
  key TEXT PRIMARY KEY,             -- 'kis_access_token'
  value TEXT NOT NULL,
  expires_at INTEGER NOT NULL       -- Unix timestamp
);

-- Web Push 구독 (0005)
CREATE TABLE push_subscriptions (
  endpoint TEXT PRIMARY KEY,
  p256dh TEXT NOT NULL,
  auth TEXT NOT NULL,
  user_agent TEXT,
  label TEXT,
  created_at INTEGER NOT NULL,
  last_used_at INTEGER
);

-- 배치 step 체크포인트 (0006 — 구 Durable Object 내장 SQLite에서 이관)
CREATE TABLE step_cache (
  run_id TEXT NOT NULL,             -- 'us-YYYY-MM-DD' | 'manual-...' 등
  step_name TEXT NOT NULL,
  result TEXT NOT NULL,             -- step 결과 JSON
  created_at INTEGER NOT NULL,      -- 14일 후 배치가 정리
  PRIMARY KEY (run_id, step_name)
);
```

### 운영 정책
- D1 무료 한도 (rows read 500만/일, rows written 10만/일, 5GB)는 본 프로젝트 사용량의 100배 이상 여유.
- `step_cache`는 배치가 `created_at` 기준 14일 후 자동 정리.
- R2(`cloudstock` 버킷)는 시장 요약 음성(WAV)만 캐시 — cron이 합성해 저장하고 `/api/tts`가 서빙. 그 외 시계열 데이터(snapshots, news, ai_summary)는 장기 누적 방지용 자동 삭제 cron을 향후 검토.
- 섹터 큐레이션 변경은 D1 직접 SQL 또는 별도 관리 페이지 (Phase 5에서 검토).

---
## AI 파이프라인 설계

### 모델 역할 분리

> **Phase 3 단순화 (2026-05-22)**: 원래 기획은 Kimi K2.6 + Gemma + Perplexity 3축이었으나, 실제 작업이 모두 "고정 입력 → 한국어 요약"이고 종목 선정도 `pickMovers()`로 deterministic하게 결정되어 agentic 모델·웹 서치가 불필요했음. 단일 `summarize()` 함수가 시장 요약과 mover 한줄 요약 모두 처리.
>
> **Gemini 제거 (2026-05-23)**: 한때 Google Gemini API 1차 + Gemma 폴백 2단 구조였으나, Gemini 프리티어 응답 불안정성과 thinking 토큰 truncation 이슈로 전면 제거. 시장 요약·mover 한줄 요약·대시보드 일반 뉴스 번역은 Workers AI Gemma 단일 경로다.
>
> **개별 종목 뉴스 번역 분리 (2026-05-23)**: 개별 종목 클릭 시 가져오는 관련 뉴스 5건의 헤드라인 번역은 GLM flash(`@cf/zai-org/glm-4.7-flash`)로 분리. `translateHeadlines(env, items, model?)` 에 모델 인자를 추가해 호출부가 모델을 선택한다(기본값은 `GEMMA_MODEL`이라 대시보드 일반 뉴스 경로는 그대로). 번역 실패 항목은 드롭(영문 누출 방지)하며, 원본이 있는데 전부 드롭되면 `news` 필드를 캐시하지 않아 다음 클릭에서 재시도한다(빈 결과 영구 고착 방지). mover 한줄 요약 LLM 컨텍스트는 번역 전 영문 원본을 그대로 사용해 GLM 번역 성공 여부와 무관하게 요약 품질을 보전.

| 역할 | 모델 | 호출 빈도 | 비용 |
|------|------|----------|------|
| 시장 요약 + mover 한줄 요약 + 대시보드 일반 뉴스 번역 | Workers AI Gemma (`GEMMA_MODEL`, 기본 `@cf/google/gemma-4-26b-a4b-it`) | 하루 1회 + Top 20 mover × 1회 + on-demand 종목 클릭(요약) | 입력 $0.10/M, 출력 $0.30/M |
| 개별 종목 관련 뉴스 헤드라인 번역 | Workers AI GLM flash (`GLM_MODEL`, 기본 `@cf/zai-org/glm-4.7-flash`) | on-demand 종목 클릭 × 1회 (결과는 D1 `ai_summary` 에 캐시) | Workers AI 종량 |
| 범용 웹 서치 | Perplexity Search API | (미사용, 향후 확장 후보 — `PERPLEXITY_API_KEY` 만 예약) | - |
| Kimi K2.6 | - | (미사용, 위 비고 참고) | - |

모델 슬러그는 코드에 박지 않고 `wrangler.toml [vars]`의 `GEMMA_MODEL` / `GLM_MODEL`로 노출. 모델 교체는 wrangler 변수만 수정 후 재배포.
### 호출 동작

저수준 래퍼 `worker/ai/summarize.ts`의 `callGemma(env, prompt, maxTokens, model?)` 가 Workers AI 호출을 담당한다. `summarize(env, prompt, opts?)` 는 그 위에서 `GEMMA_MODEL` 로 시장 요약·mover 한줄 요약을 호출하고, `translateHeadlines(env, items, model?)` 는 같은 래퍼를 쓰되 model 인자로 호출부 모델(기본 `GEMMA_MODEL` = 대시보드 일반 뉴스, 개별 종목 관련 뉴스는 `GLM_MODEL`)을 넘긴다:

1. `env.AI.run(model, {messages, max_tokens, temperature: 0.4, chat_template_kwargs: {enable_thinking: false}})` 호출. `model` 기본값은 `env.GEMMA_MODEL`. `max_tokens` 는 호출부 지정 (시장 요약 2048, 그 외 기본 1024).
2. `enable_thinking: false` — Gemma 4 / GLM 등 thinking 모델의 reasoning 단계를 끄는 vLLM 표준 옵션. 켜져 있으면 max_tokens 안에서 reasoning 만 소비하다 `content` 가 빈 문자열로 돌아온다.
3. 응답 shape 은 모델·게이트웨이마다 달라 `{response}` / `{result:{response}}` / OpenAI 호환 `{choices[].message.content}` 를 모두 관용 추출 (`extractText`).
4. 시장 요약·mover 한줄 요약은 실제 응답 모델 슬러그(`env.GEMMA_MODEL`)를 결과에 함께 담아 D1 `ai_summary.model` 에 영속화 — 운영 중 어떤 모델이 응답했는지 추적 가능.

> 폴백 경로는 없다. Gemma 호출이 예외/빈 응답/파싱 불가면 해당 step 이 throw 하며, 같은 cron 의 다른 산출물과 시세 저장은 산출물별 try/catch 격리로 보호된다 ([[CloudStock#s-1.11.4]] 참고). JSON 파싱 실패는 `tryParseJson` 으로 마크다운 펜스·앞뒤 잡설을 관용 처리.
### 호출 패턴

```typescript
// worker/ai/summarize.ts (요지)
export async function summarize(
  env, prompt, opts = {},
): Promise<{ text: string; model: string }> {
  const maxTokens = opts.maxTokens ?? 1024;
  const text = await callGemma(env, prompt, maxTokens);  // env.AI.run
  return { text, model: env.GEMMA_MODEL };
}
```
### 프롬프트 가이드라인

**시스템 프롬프트 공통 원칙**
- 출력 언어: 한국어, 경어
- 톤: 40~60대 개인 트레이더가 읽는 경제 신문 칼럼 말투
- 길이: 시장요약 신문 칼럼 한 단(약 300~500자) 분량 4섹션 아티클, 종목 한줄요약 50자 이내
- 금지: "예측", "전망", "추천" — 어제 일어난 사실 관찰만
- 의무: 모든 주장에 출처 URL 함께 출력 (가능할 때)
- 응답은 항상 JSON (마크다운 펜스/잡설 없이). 파싱 실패는 관용적으로 처리 (`tryParseJson`).

**시장 요약 시스템 프롬프트 (요지)**

```
당신은 한국 경제 신문의 시장 마감 코너 기자입니다.
어제 미국 시장에서 일어난 일을 다년차 개인 트레이더가
읽기 좋게 칼럼 한 단 분량으로 정리합니다.

규칙:
- 총 분량 300~500자 (headline + leadSector + keyIssues + driver 합산).
- headline: 한 줄 표제, 30자 이내.
- leadSector: 1~2문장. 그날 가장 두드러진 섹터와 그 이유.
- keyIssues: 1~3건. 연준/금리, 백악관/행정명령, 거시 지표,
  지정학 이슈를 우선 채택. 종목 실적·M&A 뉴스는 제외.
- driver: 1~2문장. 지수/평균 등락의 메커니즘.
- 경어, 미래 단어 금지, 컨텍스트의 사실만 인용.
- 각 텍스트 항목에 source URL 동봉 (없으면 생략).

JSON으로만 출력:
{
  "headline": "...",
  "leadSector": { "text": "...", "source": "..." },
  "keyIssues": [ { "text": "...", "source": "..." } ],
  "driver":     { "text": "...", "source": "..." }
}
```

**Mover 한줄 요약 시스템 프롬프트 (요지)**

```
종목 코너 기자. 어제 특정 종목이 크게 움직인 이유를 1문장(50자 이내)으로.
종목 뉴스에 단서가 없으면 시세 동향만 짧게.
JSON: { "summary": "...", "source": "https://..." }
```

전체 본문은 `worker/ai/prompts.ts` 참고.

---

## 10. UI/UX 설계

### 디바이스 브레이크포인트
```
모바일      ~ 768px   : S Ultra 세로, 1컬럼
태블릿      768~1280px: S Ultra 가로 / 소형 노트북, 2컬럼
데스크탑   1280px ~  : 노트북, 2~3컬럼 그리드
와이드     1600px ~  : 젠북 듀오 확장, 사이드바 고정 가능
```

### 페이지 구조
```
┌──────────────────────────────────────────────────┐
│ [헤더] 날짜 · 마지막 업데이트 시각 · 미국/한국 탭 │
└──────────────────────────────────────────────────┘

━ [A] 한눈 요약 (스크롤 없이 보이는 영역) ━━━━━━━━━
   AI 3-bullet 시장 요약 (큰 글씨, 경어)
   지수 8개 카드 (TradingView Symbol Overview)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━ [B] 시장 히트맵 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   TradingView Stock Heatmap
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━ [C] 오늘의 주인공들 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
   상승 Top 10 / 하락 Top 10
   카드: 종목명 · 등락률 · AI 한줄요약 · 출처
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━ [D] 섹터 탭 (관심 있을 때 들어가는 영역) ━━━━━━━━
   탭: 시총Top10 / AI / 반도체 / 조선 / 로봇 / ...
   각 탭별 종목 카드 리스트
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━ [E] 뉴스 (참고용, 맨 아래) ━━━━━━━━━━━━━━━━━━━━
   헤드라인 5개 + 출처 링크
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```

### 설계 원칙
- **스크롤 1번으로 전체 그림**: 탭 전환·드릴다운 최소화. 핵심 정보는 메인 페이지에.
- **숫자보다 색이 먼저**: 등락률 텍스트보다 색상·아이콘 우선. TradingView 위젯 기본 컬러 활용.
- **에러 상태 사용자 노출 금지**: stale-while-revalidate 패턴. 데이터 없으면 직전 데이터 + 타임스탬프 표시.
- **AI 요약은 신문 칼럼 말투**: 리포트체 금지.
- **호버 인터랙션 활용**: 데스크탑에서 카드 호버 시 추가 정보. 모바일은 탭 → 하단 시트.
- **섹터 탭은 와이드에서 사이드바로**: 1600px+ 환경에서 좌측 고정 네비게이션.

### PWA 설정
```json
{
  "name": "주식 레이더",
  "short_name": "레이더",
  "display": "standalone",
  "background_color": "#0f0f0f",
  "theme_color": "#0f0f0f",
  "start_url": "/",
  "icons": [...]
}
```

`display: standalone`으로 설치 시 주소창·탭바 제거, 앱 전환 화면에 "주식 레이더" 아이콘으로 표시.

---

## 11. 구현 단계 (Phases)

### Phase 0 — 프로비저닝 (0.5일)
- 도메인 `stock.vialinks.xyz` Cloudflare 연결 + Worker 라우트 설정
- Workers Paid $5/월 가입
- KIS Developers 가입 + 앱키 발급 (Phase 4용 미리 준비)
- FMP / Finnhub 무료 API 키 발급
- Perplexity API 키 발급
- GitHub 레포 생성 + Cloudflare Workers 연동 (Pages 불필요)
- D1 데이터베이스 생성

### Phase 1 — 정적 프론트 + TradingView (1일)
- Astro 프로젝트 초기화
- Bootstrap 5 + MDI/BI 웹폰트 통합
- TradingView 위젯 8개 임베드 (지수 카드)
- TradingView Stock Heatmap 임베드
- `wrangler.toml` `[assets] directory = "./dist/"` 설정, Workers 단독 배포 확인
- IP 화이트리스트 WAF Rule + Worker 미들웨어 동작 확인
- PWA manifest.json 작성, 설치 가능 상태

**완료 기준**: 엄마가 모바일에서 PWA로 설치하면 지수 8개 + 히트맵이 실시간으로 표시됨.

### Phase 2 — 미국 EOD 스냅샷 (1~2일)
- Hono Worker 백엔드 셋업
- Agents SDK 도입, StockRadarAgent 클래스 작성
- Cron Trigger `0 22 * * *` 등록 (07:00 KST)
- FMP batch-quote 호출 → D1 snapshots 저장
- Finnhub 뉴스 5개 → D1 news 저장
- 프론트엔드 `/api/snapshot/us`, `/api/news` 라우트
- AI 요약은 아직 하드코딩 또는 생략

**완료 기준**: 매일 아침 7시에 데이터가 자동 갱신되어 전날 종가가 표시됨.

> **DO 제거 (2026-05-25, PR #38)**: 자율 뉴스 탐색이 불필요해 Agents SDK Durable Object를 폐기. `StockRadarAgent` 클래스를 일반 모듈 함수(`worker/batch/stockRadar.ts`)로 전환하고 step 체크포인트는 D1 `step_cache`(0006)로 이관. cron도 평일 한정 `0 22 * * MON-FRI`로 변경. 별도 `cloudstock-preview` 워커를 폐기하고 메인 워커가 버전 프리뷰 URL(`*.workers.dev`, 호스트 가드로 읽기전용)을 직접 발급. 상세는 [[CloudStock#s-1.6]] · [[CloudStock#s-1.13.1]] 참고.
### Phase 3 — AI 요약 파이프라인 (2일) ✅

**구현 완료 (2026-05-22, PR #5). 4섹션 아티클 형식 확장 (2026-05-23). mover 파이프라인 fail-open 전환 + 개별 종목 on-demand 요약 추가 (2026-05-23, PR #28·#29).**

- 종목별 Finnhub `/company-news` step (Top 20 mover, 직렬 + 100ms gap)
- 시장 요약 step: Workers AI Gemma → `{headline, leadSector, keyIssues[], driver}` 4섹션 아티클 (신문 칼럼 한 단, 500~800자)
- mover 한줄 요약 step: 각 종목별 Gemma → `{summary, source?}`
- `ai_summary` 테이블 unique index(`market, kind, target_date, COALESCE(ticker, '')`) + ON CONFLICT upsert. payload 는 TEXT(JSON) 이라 스키마 변경 시 별도 마이그레이션 불필요.
- `GET /api/summary/:market` 라우트, 프론트 `#market-summary` 타일에 headline · 주목 섹터 · 핵심 이슈 리스트 · 시장 동인 4파트 렌더, 상승/하락 Top 10 각 row 아래 한줄 요약
- 모든 모델 슬러그는 `GEMMA_MODEL` env로 노출 (코드 하드코딩 없음)
- AI 단계는 산출물별(`summarize_market_v2`/`persist_market`, `summarize_movers`/`persist_movers`)로 독립 try/catch 격리 — 한쪽 실패가 다른쪽 영속화나 시세 저장(`persist`)을 막지 않음

**fail-open 전환 (PR #28)**: 기존엔 mover 한줄요약이 ticker 1개라도 실패하면 step 전체가 throw → `persist_ai` 미실행 → market_overview 까지 함께 폐기되어 운영 D1 에 `mover_oneliner` row 가 0 건 쌓이는 버그가 있었다. `summarize_movers` 를 성공한 ticker 만 반환하고 전부 실패한 경우에만 throw 하도록 바꾸고, 영속화 step 을 market/movers 로 분리했다.

**개별 종목 on-demand 요약 (PR #29)**: 상승/하락 Top10 + 섹터 탭의 종목 행을 클릭하면 SweetAlert2 모달이 열려 AI 한 줄 요약 + 관련 뉴스 3~5건을 표시한다.
- 신규 라우트 `GET /api/stock/:ticker?market=us` — `tickers` whitelist 검증, 최신 snapshot 날짜를 캐시 키로 사용
- 캐시 히트(payload 에 `news` 필드 존재) 시 LLM/Finnhub 호출 없이 즉시 반환. cron 이 채운 Top10 oneliner 는 첫 클릭 시 Finnhub fetch 로 `news` 보강
- 캐시 판정은 "뉴스를 fetch 한 적이 있는가"로 구분 — finnhub 성공 시에만 `news` 필드를 payload 에 저장(0건이어도). 실패 시 필드 생략해 일시 장애가 빈 결과로 고착되지 않고 다음 호출에 재시도
- 프론트는 monotonic request id 로 stale 응답(빠른 연속 클릭) 가드, 모든 UI 텍스트 한국어, 기존 `escapeHtml`/`safeHref` 로 XSS 방어

**미적용 (Phase 5로 이연)**: 비용 모니터링 알림, `ai_summary` 90일 이상 자동 삭제, 사용자별 on-demand 호출 레이트리밋(현재는 ticker/일 캐시로 자연 제한).

**완료 기준**: 아침 7시 데이터에 한국어 시장 요약과 특징주 이유가 자동 생성됨. (수동 트리거 검증 완료 — mover_oneliner 정상 적재 확인)
### Phase 4 — 한국주식 매시간 (2~3일)

#### 파이프라인 개요

미장과 구조는 동일하나 호출 패턴이 다르다.

```
미장: 매일 1회 배치 → 정적 요약
한국장: 평일 9시~15시 30분, 1시간마다 6~7회 → 실시간 등락률 + 관련 이슈
```

```
KIS (시세)               → 섹터 인덱스 등락률 + 개별 종목 등락률
Perplexity Search API    → 섹터별 뉴스/이슈 (섹터당 별도 쿼리)
  └─ query: "{섹터명} 관련주 뉴스 이슈"
  └─ search_recency_filter: "day"
  └─ country: "KR", search_language_filter: ["ko"]
Workers AI (Gemma 4 / GLM Flash) → 섹터별 한국어 요약
웹 푸시 + 대시보드      → 결과 전송
```

#### 스케줄링

- **Cron**: `0 0-7 * * 1-5` (UTC) = KST 9시~16시 평일 매시간 정각
- **장 운영 시간 필터**: Worker 내부에서 KST 9:00~15:30 범위 체크 (15시 이후 호출은 15시 30분 마감 전 마지막 1회로 처리)
- **공휴일 처리**: D1 `kr_holidays` 테이블에 연간 공휴일 날짜 사전 입력, cron 발화 시 오늘 날짜 체크 후 스킵

#### KIS 시세 수집

- **섹터 인덱스 등락률**: KIS `/uapi/domestic-stock/v1/quotations/inquire-index-price` — 업종 코드별 인덱스 등락률
- **개별 종목 등락률**: KIS `/uapi/domestic-stock/v1/quotations/inquire-price` — sectors 테이블 종목 순회
- **Self-throttle**: 초당 15건 (KIS 실전 20건 한도 대비 여유)
- **토큰 캐시**: D1 `token_cache` 테이블, 만료 1시간 전 재발급

#### 섹터 구성 (업종 단위)

| 섹터 슬러그 | 표시명 | KRX 업종 코드 |
|------------|--------|--------------|
| `semiconductor` | 반도체 | (큐레이션) |
| `shipbuilding` | 조선·해양 | (큐레이션) |
| `defense` | 방산 | (큐레이션) |
| `battery` | 2차전지 | (큐레이션) |
| `bio` | 바이오·제약 | (큐레이션) |
| `finance` | 금융·은행 | (큐레이션) |

업종 코드 및 종목 큐레이션은 D1 `sectors` 테이블에 수동 입력 (`market='kr'`).

#### Perplexity Search — 섹터별 쿼리 패턴

섹터당 쿼리 1건씩 별도 호출. 미장 Finnhub와 역할 동일 (raw 검색결과 → 에이전트 컨텍스트).

```js
// 섹터별 쿼리 예시
const sectorQueries = {
  semiconductor: "반도체 관련주 뉴스 이슈 오늘",
  shipbuilding:  "조선 해양 관련주 뉴스 이슈 오늘",
  defense:       "방산 관련주 뉴스 이슈 오늘",
};

// 공통 파라미터
{
  max_results: 5,
  search_recency_filter: "day",
  country: "KR",
  search_language_filter: ["ko"],
}
```

비용: 섹터 6개 × 매시간 × 6.5시간 × 영업일 ≈ 일 ~40건, 월 ~840건 → **월 ~$4.2**

#### AI 요약

섹터별 요약은 Gemma 4 (`GEMMA_MODEL`) 담당. 미장 mover 한줄 요약과 동일한 `summarize()` 경로 재사용.

프롬프트 방향:
- 섹터 인덱스 등락률 + 상위 등락 종목 + Perplexity 검색결과 → 섹터 동향 2~3문장
- "현재 가 %  중. "

#### 알림 전송

웹 푸시는 이미 구현됨. 한국장 hourly 결과를 기존 푸시 채널 그대로 활용.
대시보드는 `/api/snapshot/kr` 라우트 추가, 미장 탭과 동일한 UI 패턴 적용.

#### 구현 항목

- KIS 토큰 발급 + `token_cache` 패턴 (D1)
- KIS 섹터 인덱스 + 개별 종목 inquire-price (15/sec self-throttle)
- 공휴일 테이블 (`kr_holidays`) + cron 스킵 로직
- Perplexity Search 섹터별 병렬 호출
- 섹터 요약 step (Gemma 4)
- 평일 매시간 cron `0 0-7 * * 1-5`
- D1 `sectors` 테이블 한국 종목 큐레이션 수동 입력
- `/api/snapshot/kr` + `/api/sector/kr/:name` 라우트
- 대시보드 한국장 탭 (섹터 등락률 카드 + 개별 종목 등락률)

**완료 기준**: 장중 매시간 한국주식 섹터별 등락률 + 관련 이슈 요약이 푸시 알림과 대시보드에 자동 갱신됨.

상세 파이프라인 설계: [[CloudStock/KR파이프라인]]

### Phase 5 — 부가 기능 (선택)
- **종목 성장률 비교 차트**: 종목 두 개 멀티셀렉트 → 동일 축 겹쳐 보기. 1M/3M/6M/1Y 토글. D1 EOD 누적 데이터 기반 (신규)
- **테마별 상승률 히트맵**: 자체 큐레이션 섹터별 당일 평균 등락률을 색상 강도로 표시. TradingView 위젯 히트맵과 병존, D1 기반 자체 계산 (신규)

---

## 12. 비용 예상

### 고정비
| 항목 | 월 비용 |
|------|--------|
| Cloudflare Workers Paid | $5 |
| Cloudflare D1 무료 한도 내 | $0 |
| Cloudflare Pages | $0 |
| 도메인 | ~$1 (연 $10~15) |
| **고정비 소계** | **$6** |

### 변동비 (예상 일 사용량 기준)
| 항목 | 일 사용량 | 월 비용 |
|------|----------|--------|
| FMP API | 무료 250 req/일 내 | $0 (필요 시 $22) |
| Finnhub | 무료 60 req/min 내 | $0 |
| Perplexity Search API | 5~10 쿼리/일 | ~$1~2 |
| Workers AI Kimi K2.6 | 시장요약 + 특징주 요약 | ~$1~2 |
| Workers AI Gemma 4 26B A4B | 정형화 작업 | ~$0.5 |
| KIS Open API | 무료 (계좌만) | $0 |
| **변동비 소계** | | **~$8~10** |

### 월 총 예상 비용: **$14~16** (FMP 무료티어 유지 시)

> 비용 모니터링: Cloudflare Analytics + 월 1회 사용량 점검 루틴.

---

## 13. 기술적 제약 및 함정

### Cloudflare 관련
1. **Workers Cron은 UTC 기준**. 07:00 KST = UTC 22:00 (전날). DST 변경 시 미국 마감이 1시간 밀리므로 3월/11월에 확인.
2. **Cron 실패 자동 재시도 없음**. D1 `step_cache`(step 체크포인트)로 부분 실패 재시작을 보완. 그래도 전체 실패 시 외부 알림(웹훅)으로 감지.
3. **Cron propagation 최대 15분**. 정밀 타이밍 불필요한 시스템이므로 영향 없음.
4. **Workers Free 플랜 CPU 10ms, 50 subrequest**로 사실상 불가. Paid 필수. (1회 배치 subrequest 약 100여 개로 Paid 1,000 한도 내.)
5. **Durable Object 미사용 — 프리뷰 URL 제약**. DO를 구현한 워커에는 버전 프리뷰 URL이 발급되지 않는다. 배치를 모듈 함수 + D1 step_cache로 두어 DO를 없앴고, 이제 메인 워커가 직접 버전 프리뷰 URL(`*.workers.dev`)을 발급한다. 단, DO 마이그레이션(생성·삭제)은 `wrangler versions upload`로 적용 불가(code 10211)하므로 **프로덕션 배포는 `wrangler deploy`** 로 한다(브랜치 빌드는 versions upload). DO 삭제 마이그레이션을 담은 PR의 브랜치 빌드는 10211로 실패하는 게 정상이며, main의 `wrangler deploy`가 적용한다.
### KIS Open API 관련
5. **계좌 휴면 자동 해지** (3개월 미거래). 월 1회 모의 거래 자동화 권장.
6. **신규 고객 초당 호출 제한 변경** (2026.03.20 공지). 발급 후 본인 한도 재확인.
7. **슬라이딩 윈도우 레이트리밋**. 실전 초당 20건이지만 self-throttle은 15/sec 권장.
8. **토큰 24h TTL**. token_cache에서 만료 임박 시 재발급.

### 데이터 소스 관련
9. **Yahoo Finance / yfinance 사용 금지**. ToS 리스크 + 스크래핑 차단 강화.
10. **TradingView 위젯은 표시 전용**. 위젯 데이터 스크래핑/API 호출은 ToS 위반.
11. **Perplexity Search API 한국어 뉴스 깊이 변동성**. `search_domain_filter`로 한국 매체 명시 권장.

### AI 모델 관련
12. **AI 요약은 "예측"이 아닌 "관찰"로 프롬프트**. 환각 최소화.
13. **K2.6은 Sonnet급 벤치마크지만 복잡 통합 태스크에서는 갭 존재 가능**. 품질 이슈 발생 시 프롬프트 튜닝으로 대응.
14. **Project Think / 고급 Agents SDK 기능은 preview**. 기본 Agent + step 패턴만 사용.

### 보안 관련
15. **CF-Connecting-IP는 외부 진입 워커에서만 신뢰 가능**. Worker 간 cross-zone fetch에서 IP가 치환될 수 있으므로 1차 검증은 최외곽에서.
16. **API 키 클라이언트 노출 금지**. Worker `env`에서만 접근. CORS는 자체 도메인 한정.

### 운영 관련
17. **첫 데이터 수집 실패 시 사용자에게 빈 화면 노출 위험**. Phase 1에서 더미 데이터 시드 + stale-while-revalidate 구현 필요.
18. **시계열 데이터 무한 누적 방지**. 90일 이상 자동 삭제 cron 추가 (Phase 5).

---

## 14. 향후 확장 가능성

### 단기
- 한국주식 기획 본격화 (Phase 4 상세 설계)
- 관심 종목 즐겨찾기 기능

### 중기
- 과거 데이터 비교 기능 (지난주/지난달 같은 시점)
- 섹터 큐레이션 관리 페이지 (D1 직접 수정 대체)
- 비용 최적화: Perplexity 호출 빈도 조정, K2.6 캐시 입력 적극 활용

### 장기 (필요 시)
- R2 추가하여 과거 1년 이상 원본 데이터 아카이브
- Workers Workflows로 파이프라인 분리 (CPU 30s 단계별 분할)
- 시계열 차트 자체 구현 (TradingView 위젯 의존도 축소)

---

## 부록 A — 환경변수 목록

```bash
# wrangler.toml [vars] 또는 Cloudflare Dashboard Secrets

# 보안 / 인증
AUTH_PASSWORD=...                     # 로그인 단일 비밀번호 + JWT HS256 서명 키. 회전 시 전 세션 무효.
TURNSTILE_SECRET_KEY=...              # Cloudflare Turnstile siteverify 시크릿 (로그인 브루트포스 방어)
AUTH_IP_WHITELIST=...                 # (선택) 쉼표/공백 구분 IP 목록. CF-Connecting-IP 포함 시 인증 자동 통과. secret 주입.
AGENT_ENABLED="false"                 # cron 발화 시 fetchUSMarket 실행 토글

# AI 모델 슬러그 (평문 vars, 교체 용이성)
GEMMA_MODEL="@cf/google/gemma-4-26b-a4b-it"   # 시장 요약 / mover 한줄 요약 / 대시보드 일반 뉴스 번역
GLM_MODEL="@cf/zai-org/glm-4.7-flash"         # 개별 종목 클릭 시 관련 뉴스 헤드라인 번역

# 외부 API 키 (Secrets 권장)
FMP_API_KEY=...
FINNHUB_API_KEY=...
GEMINI_API_KEY=...        # 시장 요약 음성 듣기(/api/tts) — Gemini 3.1 Flash TTS (gemini-3.1-flash-tts-preview). 텍스트 요약엔 미사용(Gemma 단일 경로).
PERPLEXITY_API_KEY=...    # Phase 3에서는 미사용 (향후 옵션)
KIS_APP_KEY=...           # Phase 4 이후
KIS_APP_SECRET=...        # Phase 4 이후

# 운영
NOTIFY_WEBHOOK_URL=...    # Discord/Telegram 웹훅 (Phase 5)
```

Turnstile **사이트키**(`0x4AAAAAADUwrG2i3-UBqcO8`) 는 공개 정보로 `src/pages/login.astro` 에 하드코딩. 시크릿만 `wrangler secret put` 으로 주입한다.
## 부록 B — Cloudflare 바인딩 목록

```toml
# wrangler.toml (요지 — 전체는 저장소 wrangler.toml)

workers_dev = true
preview_urls = true     # PR 버전 프리뷰 URL(*.workers.dev) 발급

[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*"]

[[d1_databases]]
binding = "DB"
database_name = "cloudstock"
migrations_dir = "migrations"

[ai]
binding = "AI"

[[r2_buckets]]          # 시장 요약 TTS(WAV) 캐시
binding = "R2"
bucket_name = "cloudstock"

# Durable Object 없음. 구 StockRadarAgent DO는 v2 deleted_classes 로 제거됨.
# (v1 new_sqlite_classes 이력은 deploy 가 deleted_classes 를 적용하도록 남겨둔다.)
[[migrations]]
tag = "v1"
new_sqlite_classes = ["StockRadarAgent"]
[[migrations]]
tag = "v2"
deleted_classes = ["StockRadarAgent"]

[triggers]
crons = ["0 22 * * MON-FRI"]   # 07:00 KST 평일 (미국 마감)
# Phase 4: 한국 평일 매시간 cron 추가 예정

[limits]
cpu_ms = 300000  # 5분 (긴 배치 대응)
```

> 프리뷰(PR 브랜치)는 별도 워커 없이 메인 워커의 버전 프리뷰 URL로 서빙된다. `*.workers.dev` 호스트 요청은 `worker/index.ts` 미들웨어가 D1/R2 읽기전용 래퍼 + Web Push 스킵으로 강등해 공유 prod 데이터를 보호한다. (구 `cloudstock-preview` 별도 워커는 폐기.)

---

*문서 끝.*