Compare commits
5
Commits
8bc43b3ce4
...
449b6d8ca3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449b6d8ca3 | ||
|
|
ab16eaf5a7 | ||
|
|
a482a0a3e3 | ||
|
|
f7b8d895c3 | ||
|
|
920d9358a0 |
@@ -11,6 +11,11 @@ TELEGRAM_CHAT_ID=
|
|||||||
# FRED (선택) — 경제지표 실제 발표값
|
# FRED (선택) — 경제지표 실제 발표값
|
||||||
FRED_API_KEY=
|
FRED_API_KEY=
|
||||||
|
|
||||||
|
# SearXNG (선택) — 설정하면 RSS 외에 메타검색 뉴스도 수집 (인스턴스에 json 포맷 활성화 필요)
|
||||||
|
SEARXNG_BASE_URL=
|
||||||
|
# 검색어 CSV. 비우면 기본값(us2kr/collectors/searx.py DEFAULT_QUERIES)
|
||||||
|
US2KR_SEARXNG_QUERIES=
|
||||||
|
|
||||||
# 실행 정책
|
# 실행 정책
|
||||||
US2KR_RUN_OFFSET_MINUTES=75
|
US2KR_RUN_OFFSET_MINUTES=75
|
||||||
US2KR_CATCHUP_WINDOW_HOURS=3
|
US2KR_CATCHUP_WINDOW_HOURS=3
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ reports/
|
|||||||
logs/
|
logs/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
node_modules/
|
||||||
|
dashboard/dist/
|
||||||
|
|||||||
@@ -57,9 +57,14 @@ cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택
|
|||||||
| 환경 | 방법 |
|
| 환경 | 방법 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| macOS | `bash deploy/install-launchd.sh` (launchd, KeepAlive) |
|
| macOS | `bash deploy/install-launchd.sh` (launchd, KeepAlive) |
|
||||||
|
| macOS (Apple container) | `bash deploy/install-apple-container.sh` — 데몬+대시보드 빌드·기동. `--boot`를 붙이면 로그인 시 자동 기동(LaunchAgent) 등록 |
|
||||||
| Linux | `deploy/us2kr.service` → `systemctl enable --now us2kr` |
|
| Linux | `deploy/us2kr.service` → `systemctl enable --now us2kr` |
|
||||||
| Docker | `docker compose -f deploy/compose.yaml up -d --build` |
|
| Docker | `docker compose -f deploy/compose.yaml up -d --build` |
|
||||||
|
|
||||||
|
Apple container(`brew install container`)는 restart 정책이 없어 재부팅 후 자동 기동은 `--boot`의
|
||||||
|
LaunchAgent(`container system start` + `container start us2kr`)가 담당합니다.
|
||||||
|
`data/` `reports/` `logs/`는 호스트 디렉터리를 그대로 마운트하므로 launchd 방식과 산출물 위치가 같습니다.
|
||||||
|
|
||||||
> macOS 노트북은 **잠자기 중에는 실행되지 않습니다.** 06~07시에 깨어 있도록
|
> macOS 노트북은 **잠자기 중에는 실행되지 않습니다.** 06~07시에 깨어 있도록
|
||||||
> `sudo pmset repeat wakeorpoweron MTWRFS 06:00:00` 로 예약 기동을 걸거나, 항상 켜진 머신/서버에서 돌리세요.
|
> `sudo pmset repeat wakeorpoweron MTWRFS 06:00:00` 로 예약 기동을 걸거나, 항상 켜진 머신/서버에서 돌리세요.
|
||||||
|
|
||||||
@@ -76,6 +81,8 @@ cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택
|
|||||||
| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 |
|
| `US2KR_CATCHUP_WINDOW_HOURS` | `3` | 재시작 시 따라잡기 허용 창 |
|
||||||
| `US2KR_SKIP_WHEN_KRX_CLOSED` | `false` | 한국 휴장일에 다음 미국 세션이 먼저 마감되어 브리핑이 무의미해질 때만 스킵(금요일 세션은 항상 발송) |
|
| `US2KR_SKIP_WHEN_KRX_CLOSED` | `false` | 한국 휴장일에 다음 미국 세션이 먼저 마감되어 브리핑이 무의미해질 때만 스킵(금요일 세션은 항상 발송) |
|
||||||
| `US2KR_NEWS_LOOKBACK_HOURS` / `US2KR_NEWS_MAX_ITEMS` | `18` / `40` | 뉴스 수집 범위 |
|
| `US2KR_NEWS_LOOKBACK_HOURS` / `US2KR_NEWS_MAX_ITEMS` | `18` / `40` | 뉴스 수집 범위 |
|
||||||
|
| `SEARXNG_BASE_URL` | — | 있으면 RSS 외에 SearXNG 뉴스 검색 결과도 병합 (인스턴스에 `json` 포맷 활성화 필요) |
|
||||||
|
| `US2KR_SEARXNG_QUERIES` | 기본 검색어 3개 | SearXNG 검색어 CSV (기본: `us2kr/collectors/searx.py`의 `DEFAULT_QUERIES`) |
|
||||||
| `US2KR_KRX_EXTRA_HOLIDAYS` | — | 캘린더에 없는 한국 임시휴장일 CSV (예: `2026-06-03`) |
|
| `US2KR_KRX_EXTRA_HOLIDAYS` | — | 캘린더에 없는 한국 임시휴장일 CSV (예: `2026-06-03`) |
|
||||||
|
|
||||||
## 산출물
|
## 산출물
|
||||||
@@ -84,6 +91,22 @@ cp .env.example .env # MINIMAX_API_KEY 필수, TELEGRAM_* 선택
|
|||||||
- `data/us2kr.sqlite3` — 시세 스냅샷·리포트·발송 기록(전일 대비 비교, 중복 방지)
|
- `data/us2kr.sqlite3` — 시세 스냅샷·리포트·발송 기록(전일 대비 비교, 중복 방지)
|
||||||
- `logs/us2kr.log` — 일자별 회전, API 키 마스킹
|
- `logs/us2kr.log` — 일자별 회전, API 키 마스킹
|
||||||
|
|
||||||
|
## 대시보드 (dashboard/)
|
||||||
|
|
||||||
|
쌓인 브리핑을 보는 웹 대시보드(TypeScript, 런타임 의존성 없는 Node 24 서버 + 번들 클라이언트).
|
||||||
|
`reports/*.json`만 읽는 읽기 전용 뷰라 데몬의 SQLite(WAL)와 절대 경합하지 않습니다.
|
||||||
|
|
||||||
|
- 최신 세션 요약 타일(S&P·나스닥·SOX·VIX·달러/원·10년물), 종목별 종가 추이 차트(표 전환 가능)
|
||||||
|
- 브리핑 목록/본문(Markdown 렌더, sanitize), 데이터 결측·오류 로그 표시
|
||||||
|
- Apple container 배포 시 함께 기동: `http://localhost:8090` (포트: `US2KR_DASHBOARD_PORT`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard
|
||||||
|
npm install
|
||||||
|
npm run build # tsc 타입체크 + esbuild 번들
|
||||||
|
US2KR_REPORTS_DIR=../reports npm start # http://localhost:8090
|
||||||
|
```
|
||||||
|
|
||||||
## 개발
|
## 개발
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY web ./web
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:24-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production US2KR_REPORTS_DIR=/data/reports PORT=8090
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
EXPOSE 8090
|
||||||
|
USER node
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
Generated
+565
@@ -0,0 +1,565 @@
|
|||||||
|
{
|
||||||
|
"name": "us2kr-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "us2kr-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"marked": "^16.0.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||||
|
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "16.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||||
|
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "us2kr-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/server.js",
|
||||||
|
"build:client": "esbuild src/client/main.ts --bundle --minify --target=es2022 --outfile=dist/public/app.js",
|
||||||
|
"build:static": "mkdir -p dist/public && cp web/index.html web/styles.css dist/public/",
|
||||||
|
"build": "npm run typecheck && npm run build:server && npm run build:client && npm run build:static",
|
||||||
|
"start": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"marked": "^16.0.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/** Hand-rolled single-series SVG line chart with crosshair + tooltip hover. */
|
||||||
|
|
||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
const W = 920;
|
||||||
|
const H = 260;
|
||||||
|
const PAD = { top: 14, right: 16, bottom: 26, left: 58 };
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
attrs: Record<string, string | number> = {},
|
||||||
|
): SVGElementTagNameMap[K] {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceTicks(min: number, max: number, count = 4): number[] {
|
||||||
|
if (min === max) {
|
||||||
|
const pad = Math.abs(min) * 0.01 || 1;
|
||||||
|
min -= pad;
|
||||||
|
max += pad;
|
||||||
|
}
|
||||||
|
const span = max - min;
|
||||||
|
const step = 10 ** Math.floor(Math.log10(span / count));
|
||||||
|
const err = span / count / step;
|
||||||
|
const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1;
|
||||||
|
const s = step * mult;
|
||||||
|
const first = Math.ceil(min / s) * s;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let v = first; v <= max + s * 1e-9; v += s) ticks.push(Number(v.toFixed(10)));
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(v: number): string {
|
||||||
|
const decimals = Math.abs(v) >= 1000 ? 0 : Math.abs(v) >= 10 ? 2 : Math.abs(v) >= 1 ? 3 : 4;
|
||||||
|
return v.toLocaleString("ko-KR", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the chart into `host` (cleared first). Null values break the line. */
|
||||||
|
export function renderLineChart(
|
||||||
|
host: HTMLElement,
|
||||||
|
dates: string[],
|
||||||
|
values: (number | null)[],
|
||||||
|
seriesLabel: string,
|
||||||
|
): void {
|
||||||
|
host.textContent = "";
|
||||||
|
const points: (Point | null)[] = dates.map((date, i) => {
|
||||||
|
const v = values[i];
|
||||||
|
return v === null || v === undefined ? null : { date, value: v };
|
||||||
|
});
|
||||||
|
const present = points.filter((p): p is Point => p !== null);
|
||||||
|
if (present.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "chart-empty";
|
||||||
|
empty.textContent = "표시할 데이터가 없습니다.";
|
||||||
|
host.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lo = Math.min(...present.map((p) => p.value));
|
||||||
|
const hi = Math.max(...present.map((p) => p.value));
|
||||||
|
const span = hi - lo || Math.abs(hi) * 0.02 || 1;
|
||||||
|
const yMin = lo - span * 0.08;
|
||||||
|
const yMax = hi + span * 0.08;
|
||||||
|
|
||||||
|
const innerW = W - PAD.left - PAD.right;
|
||||||
|
const innerH = H - PAD.top - PAD.bottom;
|
||||||
|
const x = (i: number) => PAD.left + (dates.length === 1 ? innerW / 2 : (i / (dates.length - 1)) * innerW);
|
||||||
|
const y = (v: number) => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * innerH;
|
||||||
|
|
||||||
|
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });
|
||||||
|
const title = svgEl("title");
|
||||||
|
title.textContent = `${seriesLabel} 종가 추이`;
|
||||||
|
svg.append(title);
|
||||||
|
|
||||||
|
for (const tick of niceTicks(yMin, yMax)) {
|
||||||
|
svg.append(svgEl("line", { class: "gridline", x1: PAD.left, x2: W - PAD.right, y1: y(tick), y2: y(tick) }));
|
||||||
|
const label = svgEl("text", { class: "axis-label", x: PAD.left - 8, y: y(tick) + 4, "text-anchor": "end" });
|
||||||
|
label.textContent = formatValue(tick);
|
||||||
|
svg.append(label);
|
||||||
|
}
|
||||||
|
svg.append(
|
||||||
|
svgEl("line", { class: "baseline", x1: PAD.left, x2: W - PAD.right, y1: H - PAD.bottom, y2: H - PAD.bottom }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxLabels = Math.max(2, Math.floor(innerW / 90));
|
||||||
|
const stride = Math.max(1, Math.ceil(dates.length / maxLabels));
|
||||||
|
dates.forEach((d, i) => {
|
||||||
|
if (i % stride !== 0 && i !== dates.length - 1) return;
|
||||||
|
const label = svgEl("text", { class: "axis-label", x: x(i), y: H - 8, "text-anchor": "middle" });
|
||||||
|
label.textContent = d.slice(5); // MM-DD
|
||||||
|
svg.append(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
// polyline per contiguous run so nulls read as gaps, not interpolation
|
||||||
|
let run: string[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (run.length >= 2) svg.append(svgEl("polyline", { class: "series-line", points: run.join(" ") }));
|
||||||
|
else if (run.length === 1) {
|
||||||
|
const [px = "", py = ""] = (run[0] ?? "").split(",");
|
||||||
|
svg.append(svgEl("circle", { class: "hover-dot", cx: px, cy: py, r: 3 }));
|
||||||
|
}
|
||||||
|
run = [];
|
||||||
|
};
|
||||||
|
points.forEach((pt, i) => {
|
||||||
|
if (pt === null) flush();
|
||||||
|
else run.push(`${x(i)},${y(pt.value)}`);
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
|
||||||
|
// hover layer: crosshair + dot + tooltip on the nearest present point
|
||||||
|
const crosshair = svgEl("line", {
|
||||||
|
class: "crosshair",
|
||||||
|
y1: PAD.top,
|
||||||
|
y2: H - PAD.bottom,
|
||||||
|
x1: -10,
|
||||||
|
x2: -10,
|
||||||
|
visibility: "hidden",
|
||||||
|
});
|
||||||
|
const dot = svgEl("circle", { class: "hover-dot", r: 4.5, visibility: "hidden" });
|
||||||
|
svg.append(crosshair, dot);
|
||||||
|
|
||||||
|
const tooltip = document.createElement("div");
|
||||||
|
tooltip.className = "chart-tooltip";
|
||||||
|
tooltip.hidden = true;
|
||||||
|
host.append(svg, tooltip);
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
crosshair.setAttribute("visibility", "hidden");
|
||||||
|
dot.setAttribute("visibility", "hidden");
|
||||||
|
tooltip.hidden = true;
|
||||||
|
};
|
||||||
|
svg.addEventListener("pointerleave", hide);
|
||||||
|
svg.addEventListener("pointermove", (ev) => {
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const relX = ((ev.clientX - rect.left) / rect.width) * W;
|
||||||
|
let best: { i: number; pt: Point } | null = null;
|
||||||
|
points.forEach((pt, i) => {
|
||||||
|
if (pt === null) return;
|
||||||
|
if (best === null || Math.abs(x(i) - relX) < Math.abs(x(best.i) - relX)) best = { i, pt };
|
||||||
|
});
|
||||||
|
if (best === null) {
|
||||||
|
hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { i, pt } = best as { i: number; pt: Point };
|
||||||
|
const cx = x(i);
|
||||||
|
const cy = y(pt.value);
|
||||||
|
crosshair.setAttribute("x1", String(cx));
|
||||||
|
crosshair.setAttribute("x2", String(cx));
|
||||||
|
crosshair.setAttribute("visibility", "visible");
|
||||||
|
dot.setAttribute("cx", String(cx));
|
||||||
|
dot.setAttribute("cy", String(cy));
|
||||||
|
dot.setAttribute("visibility", "visible");
|
||||||
|
tooltip.innerHTML = "";
|
||||||
|
const dateEl = document.createElement("div");
|
||||||
|
dateEl.textContent = pt.date;
|
||||||
|
const valueEl = document.createElement("div");
|
||||||
|
valueEl.className = "tt-value";
|
||||||
|
valueEl.textContent = formatValue(pt.value);
|
||||||
|
tooltip.append(dateEl, valueEl);
|
||||||
|
tooltip.hidden = false;
|
||||||
|
tooltip.style.left = `${(cx / W) * rect.width}px`;
|
||||||
|
tooltip.style.top = `${(cy / H) * rect.height}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BriefingResult,
|
||||||
|
BriefingSummary,
|
||||||
|
ErrorLine,
|
||||||
|
HealthResponse,
|
||||||
|
Quote,
|
||||||
|
SeriesResponse,
|
||||||
|
} from "../shared/types";
|
||||||
|
import { formatValue, renderLineChart } from "./chart";
|
||||||
|
|
||||||
|
/** Symbols shown as stat tiles, in order (subset of what the daemon tracks). */
|
||||||
|
const TILE_SYMBOLS = ["^GSPC", "^IXIC", "^SOX", "^VIX", "KRW=X", "^TNX"];
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
indices: "지수",
|
||||||
|
futures: "선물",
|
||||||
|
rates: "금리",
|
||||||
|
fx: "환율/달러",
|
||||||
|
commodities: "원자재/크립토",
|
||||||
|
sectors: "섹터 ETF",
|
||||||
|
semis: "반도체",
|
||||||
|
megacap: "빅테크",
|
||||||
|
korea_linked: "한국 연관",
|
||||||
|
};
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
className?: string,
|
||||||
|
text?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byId<T extends HTMLElement>(id: string): T {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (!node) throw new Error(`missing #${id}`);
|
||||||
|
return node as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ dates & clocks
|
||||||
|
|
||||||
|
/** Today's calendar date in Seoul, as YYYY-MM-DD. */
|
||||||
|
function kstToday(): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(iso: string, delta: number): string {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + delta);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekday of a pure calendar date (0=Sun..6=Sat). */
|
||||||
|
function weekday(iso: string): number {
|
||||||
|
return new Date(`${iso}T00:00:00Z`).getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInZone(dt: Date, tz: string, withSeconds = false): string {
|
||||||
|
return new Intl.DateTimeFormat("ko-KR", {
|
||||||
|
timeZone: tz,
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
...(withSeconds ? { second: "2-digit" } : {}),
|
||||||
|
hour12: false,
|
||||||
|
}).format(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live Seoul + New York clocks in the top bar. */
|
||||||
|
function startClocks(): void {
|
||||||
|
const host = byId<HTMLElement>("clocks");
|
||||||
|
const seoul = el("span", "clock");
|
||||||
|
const ny = el("span", "clock");
|
||||||
|
host.append(seoul, ny);
|
||||||
|
const tick = () => {
|
||||||
|
const now = new Date();
|
||||||
|
seoul.textContent = `서울 ${fmtInZone(now, "Asia/Seoul", true)}`;
|
||||||
|
ny.textContent = `뉴욕 ${fmtInZone(now, "America/New_York", true)}`;
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ stat tiles
|
||||||
|
|
||||||
|
function deltaText(q: Quote): { text: string; cls: string } {
|
||||||
|
const isRates = q.group === "rates";
|
||||||
|
const v = isRates ? q.change_abs : q.change_pct;
|
||||||
|
if (v === null || v === undefined) return { text: "—", cls: "" };
|
||||||
|
const arrow = v > 0 ? "▲" : v < 0 ? "▼" : "=";
|
||||||
|
const cls = v > 0 ? "up" : v < 0 ? "down" : "";
|
||||||
|
const body = isRates ? `${(v * 100).toFixed(1)}bp` : `${Math.abs(v).toFixed(2)}%`;
|
||||||
|
return { text: `${arrow} ${body}`, cls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTiles(result: BriefingResult): void {
|
||||||
|
const host = byId<HTMLElement>("tiles");
|
||||||
|
host.textContent = "";
|
||||||
|
const quotes = new Map(result.payload.quotes.map((q) => [q.symbol, q]));
|
||||||
|
for (const sym of TILE_SYMBOLS) {
|
||||||
|
const q = quotes.get(sym);
|
||||||
|
if (!q) continue;
|
||||||
|
const tile = el("div", "tile");
|
||||||
|
tile.append(el("div", "tile-label", q.name || sym));
|
||||||
|
tile.append(el("div", "tile-value", q.close === null ? "—" : formatValue(q.close)));
|
||||||
|
const { text, cls } = deltaText(q);
|
||||||
|
tile.append(el("div", `tile-delta ${cls}`.trim(), text));
|
||||||
|
host.append(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ briefing list + body
|
||||||
|
|
||||||
|
function badgeFor(s: BriefingSummary): HTMLElement {
|
||||||
|
if (s.error) return el("span", "badge badge-error", "오류");
|
||||||
|
if (s.degraded) return el("span", "badge badge-warn", "축소");
|
||||||
|
return el("span", "badge badge-ok", "정상");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for a calendar day with no briefing. The US session feeding KRX date D is D-1:
|
||||||
|
* a weekend session day means "no US market"; today with a weekday session is just "not yet". */
|
||||||
|
function closedRow(krxDate: string, today: string): HTMLElement {
|
||||||
|
const sessionDay = weekday(addDays(krxDate, -1));
|
||||||
|
const isWeekend = sessionDay === 0 || sessionDay === 6;
|
||||||
|
const label = isWeekend ? "주말 · 미국 휴장" : krxDate === today ? "오늘 · 미국 마감 후 생성 예정" : "미국 휴장";
|
||||||
|
const item = el("li", "day-closed");
|
||||||
|
item.append(el("span", "briefing-date", krxDate), el("span", undefined, label));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(
|
||||||
|
summaries: BriefingSummary[],
|
||||||
|
selected: string,
|
||||||
|
onSelect: (krxDate: string) => void,
|
||||||
|
): void {
|
||||||
|
const list = byId<HTMLUListElement>("briefing-list");
|
||||||
|
list.textContent = "";
|
||||||
|
const byDate = new Map(summaries.map((s) => [s.krx_date, s]));
|
||||||
|
const today = kstToday();
|
||||||
|
const newest = summaries[0]?.krx_date ?? today;
|
||||||
|
const oldest = summaries[summaries.length - 1]?.krx_date ?? today;
|
||||||
|
const start = today > newest ? today : newest;
|
||||||
|
// walk the calendar day by day so gaps (weekends, US holidays) are visible, not silent
|
||||||
|
for (let d = start; d >= oldest; d = addDays(d, -1)) {
|
||||||
|
const s = byDate.get(d);
|
||||||
|
if (!s) {
|
||||||
|
list.append(closedRow(d, today));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = el("li");
|
||||||
|
const button = el("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.setAttribute("aria-current", String(s.krx_date === selected));
|
||||||
|
button.append(el("span", "briefing-date", `${s.krx_date} (KRX)`));
|
||||||
|
const line = el("span", "briefing-line");
|
||||||
|
line.append(`세션 ${s.session_date}`, badgeFor(s));
|
||||||
|
if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`));
|
||||||
|
button.append(line);
|
||||||
|
if (s.headline) button.append(el("span", "briefing-line", s.headline));
|
||||||
|
button.addEventListener("click", () => onSelect(s.krx_date));
|
||||||
|
item.append(button);
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaChips(result: BriefingResult): HTMLElement[] {
|
||||||
|
const p = result.payload;
|
||||||
|
const close = new Date(p.us_close_time);
|
||||||
|
const chips: HTMLElement[] = [
|
||||||
|
el("span", undefined, `미국 세션 ${p.session_date} → KRX ${p.krx_date}`),
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
undefined,
|
||||||
|
`마감 뉴욕 ${fmtInZone(close, "America/New_York")} · 서울 ${fmtInZone(close, "Asia/Seoul")}`,
|
||||||
|
),
|
||||||
|
el("span", undefined, `생성 ${new Date(p.generated_at).toLocaleString("ko-KR")}`),
|
||||||
|
];
|
||||||
|
if (!p.krx_open_today) chips.push(el("span", "badge badge-warn", "한국 휴장"));
|
||||||
|
if (result.degraded) chips.push(el("span", "badge badge-warn", "요약 없음"));
|
||||||
|
for (const gap of p.data_gaps) chips.push(el("span", "badge badge-warn", gap));
|
||||||
|
if (result.error) chips.push(el("span", "badge badge-error", result.error));
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact "핵심 요약" card: the LLM report's actionable parts, above the full markdown. */
|
||||||
|
function renderSummaryCard(result: BriefingResult): void {
|
||||||
|
const host = byId<HTMLElement>("report-summary");
|
||||||
|
host.textContent = "";
|
||||||
|
const report = result.report;
|
||||||
|
if (!report) return;
|
||||||
|
const card = el("section", "summary-card");
|
||||||
|
card.setAttribute("aria-label", "핵심 요약");
|
||||||
|
if (report.headline) card.append(el("p", "summary-headline", report.headline));
|
||||||
|
if (report.market_overview) card.append(el("p", "summary-overview", report.market_overview));
|
||||||
|
const blocks: Array<[string, string[], string]> = [
|
||||||
|
["한국 장 시사점", report.korea_implications, ""],
|
||||||
|
["오늘 볼 것", report.watchlist_today, ""],
|
||||||
|
["리스크", report.risk_flags, "risk"],
|
||||||
|
];
|
||||||
|
const grid = el("div", "summary-grid");
|
||||||
|
for (const [title, items, kind] of blocks) {
|
||||||
|
if (!items || items.length === 0) continue;
|
||||||
|
const block = el("div", `summary-block ${kind}`.trim());
|
||||||
|
block.append(el("h3", undefined, title));
|
||||||
|
const list = el("ul");
|
||||||
|
for (const item of items) list.append(el("li", undefined, item));
|
||||||
|
block.append(list);
|
||||||
|
grid.append(block);
|
||||||
|
}
|
||||||
|
if (grid.childElementCount > 0) card.append(grid);
|
||||||
|
host.append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBriefing(krxDate: string): Promise<void> {
|
||||||
|
const result = await getJson<BriefingResult>(`/api/briefings/${krxDate}`);
|
||||||
|
renderTiles(result);
|
||||||
|
const meta = byId<HTMLElement>("report-meta");
|
||||||
|
meta.textContent = "";
|
||||||
|
meta.append(...metaChips(result));
|
||||||
|
renderSummaryCard(result);
|
||||||
|
const body = byId<HTMLElement>("report-body");
|
||||||
|
// News titles inside the markdown come from external feeds - sanitize the rendered HTML.
|
||||||
|
body.innerHTML = DOMPurify.sanitize(marked.parse(result.markdown, { async: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ chart section
|
||||||
|
|
||||||
|
function seriesLabel(sym: string, data: SeriesResponse): string {
|
||||||
|
const entry = data.series[sym];
|
||||||
|
return entry && entry.name && entry.name !== sym ? `${entry.name} (${sym})` : sym;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChartTable(host: HTMLElement, dates: string[], values: (number | null)[]): void {
|
||||||
|
host.textContent = "";
|
||||||
|
const table = el("table", "data-table");
|
||||||
|
const head = el("tr");
|
||||||
|
head.append(el("th", undefined, "세션"), el("th", undefined, "종가"));
|
||||||
|
table.append(head);
|
||||||
|
for (let i = dates.length - 1; i >= 0; i--) {
|
||||||
|
const row = el("tr");
|
||||||
|
const v = values[i];
|
||||||
|
row.append(
|
||||||
|
el("td", undefined, dates[i] ?? ""),
|
||||||
|
el("td", undefined, v === null || v === undefined ? "—" : formatValue(v)),
|
||||||
|
);
|
||||||
|
table.append(row);
|
||||||
|
}
|
||||||
|
host.append(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupChart(data: SeriesResponse): void {
|
||||||
|
const select = byId<HTMLSelectElement>("symbol-select");
|
||||||
|
const chartHost = byId<HTMLElement>("chart");
|
||||||
|
const tableHost = byId<HTMLElement>("chart-table");
|
||||||
|
const toggle = byId<HTMLButtonElement>("table-toggle");
|
||||||
|
const chartTitle = byId<HTMLElement>("chart-title");
|
||||||
|
|
||||||
|
const symbols = Object.keys(data.series);
|
||||||
|
if (symbols.length === 0) {
|
||||||
|
chartHost.append(el("p", "chart-empty", "브리핑이 쌓이면 시세 추이가 표시됩니다."));
|
||||||
|
select.disabled = true;
|
||||||
|
toggle.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byGroup = new Map<string, string[]>();
|
||||||
|
for (const sym of symbols) {
|
||||||
|
const group = data.series[sym]?.group ?? "etc";
|
||||||
|
const bucket = byGroup.get(group) ?? [];
|
||||||
|
bucket.push(sym);
|
||||||
|
byGroup.set(group, bucket);
|
||||||
|
}
|
||||||
|
for (const [group, syms] of byGroup) {
|
||||||
|
const optgroup = document.createElement("optgroup");
|
||||||
|
optgroup.label = GROUP_LABELS[group] ?? group;
|
||||||
|
for (const sym of syms.sort()) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = sym;
|
||||||
|
option.textContent = seriesLabel(sym, data);
|
||||||
|
optgroup.append(option);
|
||||||
|
}
|
||||||
|
select.append(optgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const sym = select.value;
|
||||||
|
const entry = data.series[sym];
|
||||||
|
if (!entry) return;
|
||||||
|
chartTitle.textContent = `시세 추이 — ${seriesLabel(sym, data)}`;
|
||||||
|
renderLineChart(chartHost, data.sessions, entry.closes, seriesLabel(sym, data));
|
||||||
|
renderChartTable(tableHost, data.sessions, entry.closes);
|
||||||
|
};
|
||||||
|
select.value = symbols.includes("^GSPC") ? "^GSPC" : (symbols[0] ?? "");
|
||||||
|
select.addEventListener("change", draw);
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const showTable = tableHost.hidden;
|
||||||
|
tableHost.hidden = !showTable;
|
||||||
|
toggle.setAttribute("aria-pressed", String(showTable));
|
||||||
|
toggle.textContent = showTable ? "표 숨기기" : "표로 보기";
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ errors + health
|
||||||
|
|
||||||
|
function renderErrors(errors: ErrorLine[]): void {
|
||||||
|
if (errors.length === 0) return;
|
||||||
|
const panel = byId<HTMLElement>("errors-panel");
|
||||||
|
panel.hidden = false;
|
||||||
|
byId<HTMLElement>("errors-count").textContent = String(errors.length);
|
||||||
|
const list = byId<HTMLUListElement>("errors-list");
|
||||||
|
for (const e of errors.slice(0, 30)) {
|
||||||
|
const item = el("li");
|
||||||
|
item.append(
|
||||||
|
el("span", "err-ts", e.ts),
|
||||||
|
el("span", "err-subject", e.subject),
|
||||||
|
el("span", "err-detail", e.detail),
|
||||||
|
);
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ boot
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
startClocks();
|
||||||
|
const health = await getJson<HealthResponse>("/api/health");
|
||||||
|
byId<HTMLElement>("health").textContent =
|
||||||
|
health.report_count > 0
|
||||||
|
? `브리핑 ${health.report_count}건 · 최신 ${health.latest_krx_date}`
|
||||||
|
: "아직 쌓인 브리핑이 없습니다";
|
||||||
|
|
||||||
|
const [summaries, series, errors] = await Promise.all([
|
||||||
|
getJson<BriefingSummary[]>("/api/briefings"),
|
||||||
|
getJson<SeriesResponse>("/api/series"),
|
||||||
|
getJson<ErrorLine[]>("/api/errors"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderErrors(errors);
|
||||||
|
setupChart(series);
|
||||||
|
|
||||||
|
if (summaries.length === 0) {
|
||||||
|
byId<HTMLElement>("report-body").append(
|
||||||
|
el("p", "chart-empty", "브리핑이 아직 없습니다. 다음 미국 장 마감 후 첫 브리핑이 생성됩니다."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = summaries[0]!.krx_date;
|
||||||
|
const select = async (krxDate: string) => {
|
||||||
|
selected = krxDate;
|
||||||
|
renderList(summaries, selected, (d) => void select(d));
|
||||||
|
await showBriefing(krxDate);
|
||||||
|
};
|
||||||
|
await select(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
byId<HTMLElement>("health").textContent = "대시보드 로드 실패 — 서버 로그를 확인하세요";
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/** Read-only dashboard API + static file server.
|
||||||
|
*
|
||||||
|
* The single source of truth is the reports directory the us2kr daemon archives into
|
||||||
|
* (reports/YYYY-MM-DD.{md,json} + errors.log). Nothing here touches the SQLite DB, so the
|
||||||
|
* dashboard container never contends with the daemon's WAL over virtiofs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer, type ServerResponse } from "node:http";
|
||||||
|
import { readFile, readdir, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BriefingResult,
|
||||||
|
BriefingSummary,
|
||||||
|
ErrorLine,
|
||||||
|
HealthResponse,
|
||||||
|
SeriesResponse,
|
||||||
|
} from "./shared/types";
|
||||||
|
|
||||||
|
const REPORTS_DIR = process.env.US2KR_REPORTS_DIR ?? "/data/reports";
|
||||||
|
const PORT = Number(process.env.PORT ?? 8090);
|
||||||
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "public");
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const JSON_FILE_RE = /^\d{4}-\d{2}-\d{2}\.json$/;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ report loading
|
||||||
|
|
||||||
|
/** mtime+size keyed cache so repeated requests do not re-parse unchanged files. */
|
||||||
|
const cache = new Map<string, { mtimeMs: number; size: number; data: BriefingResult }>();
|
||||||
|
|
||||||
|
async function loadBriefing(krxDate: string): Promise<BriefingResult | null> {
|
||||||
|
const file = path.join(REPORTS_DIR, `${krxDate}.json`);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await stat(file);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cached = cache.get(file);
|
||||||
|
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.data;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await readFile(file, "utf8")) as BriefingResult;
|
||||||
|
cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, data });
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`unparseable report ${file}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDates(): Promise<string[]> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(REPORTS_DIR);
|
||||||
|
} catch {
|
||||||
|
return []; // directory not mounted yet / no briefing produced yet
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
.filter((n) => JSON_FILE_RE.test(n))
|
||||||
|
.map((n) => n.slice(0, 10))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(krxDate: string, r: BriefingResult): BriefingSummary {
|
||||||
|
return {
|
||||||
|
krx_date: krxDate,
|
||||||
|
session_date: r.payload.session_date,
|
||||||
|
generated_at: r.payload.generated_at,
|
||||||
|
krx_open_today: r.payload.krx_open_today,
|
||||||
|
model_used: r.model_used,
|
||||||
|
degraded: r.degraded,
|
||||||
|
error: r.error,
|
||||||
|
headline: r.report?.headline ?? null,
|
||||||
|
quote_count: r.payload.quotes.length,
|
||||||
|
news_count: r.payload.news.length,
|
||||||
|
data_gaps: r.payload.data_gaps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allBriefings(): Promise<Array<{ krxDate: string; result: BriefingResult }>> {
|
||||||
|
const out: Array<{ krxDate: string; result: BriefingResult }> = [];
|
||||||
|
for (const krxDate of await listDates()) {
|
||||||
|
const result = await loadBriefing(krxDate);
|
||||||
|
if (result) out.push({ krxDate, result });
|
||||||
|
}
|
||||||
|
return out; // newest first (listDates is descending)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSeries(): Promise<SeriesResponse> {
|
||||||
|
const briefings = (await allBriefings()).reverse(); // ascending for time series
|
||||||
|
// One column per US session; a session may appear once only (krx_date↔session is 1:1).
|
||||||
|
const sessions: string[] = [];
|
||||||
|
const series: SeriesResponse["series"] = {};
|
||||||
|
for (const { result } of briefings) {
|
||||||
|
const session = result.payload.session_date;
|
||||||
|
if (sessions.includes(session)) continue;
|
||||||
|
sessions.push(session);
|
||||||
|
const idx = sessions.length - 1;
|
||||||
|
for (const q of result.payload.quotes) {
|
||||||
|
let entry = series[q.symbol];
|
||||||
|
if (!entry) {
|
||||||
|
entry = { name: q.name, group: q.group, closes: [], change_pct: [] };
|
||||||
|
series[q.symbol] = entry;
|
||||||
|
}
|
||||||
|
entry.closes[idx] = q.close;
|
||||||
|
entry.change_pct[idx] = q.change_pct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pad ragged arrays so every series aligns with `sessions`
|
||||||
|
for (const entry of Object.values(series)) {
|
||||||
|
for (let i = 0; i < sessions.length; i++) {
|
||||||
|
if (entry.closes[i] === undefined) entry.closes[i] = null;
|
||||||
|
if (entry.change_pct[i] === undefined) entry.change_pct[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sessions, series };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrors(limit = 100): Promise<ErrorLine[]> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(path.join(REPORTS_DIR, "errors.log"), "utf8");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
||||||
|
return lines
|
||||||
|
.slice(-limit)
|
||||||
|
.reverse()
|
||||||
|
.map((line) => {
|
||||||
|
const [ts = "", subject = "", ...rest] = line.split("\t");
|
||||||
|
return { ts, subject, detail: rest.join("\t") };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ http plumbing
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
});
|
||||||
|
res.end(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sendStatic(res: ServerResponse, urlPath: string): Promise<void> {
|
||||||
|
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||||
|
const file = path.resolve(PUBLIC_DIR, rel);
|
||||||
|
if (!file.startsWith(PUBLIC_DIR + path.sep) && file !== path.join(PUBLIC_DIR, "index.html")) {
|
||||||
|
sendJson(res, 404, { error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await readFile(file);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream",
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 404, { error: "not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
const p = url.pathname;
|
||||||
|
try {
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
|
sendJson(res, 405, { error: "method not allowed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p === "/api/health") {
|
||||||
|
const dates = await listDates();
|
||||||
|
const body: HealthResponse = {
|
||||||
|
ok: true,
|
||||||
|
report_count: dates.length,
|
||||||
|
reports_dir: REPORTS_DIR,
|
||||||
|
latest_krx_date: dates[0] ?? null,
|
||||||
|
};
|
||||||
|
sendJson(res, 200, body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p === "/api/briefings") {
|
||||||
|
const briefings = await allBriefings();
|
||||||
|
sendJson(res, 200, briefings.map(({ krxDate, result }) => summarize(krxDate, result)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = p.match(/^\/api\/briefings\/(\d{4}-\d{2}-\d{2})$/);
|
||||||
|
if (detail) {
|
||||||
|
const krxDate = detail[1]!;
|
||||||
|
if (!DATE_RE.test(krxDate)) {
|
||||||
|
sendJson(res, 400, { error: "bad date" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await loadBriefing(krxDate);
|
||||||
|
if (!result) {
|
||||||
|
sendJson(res, 404, { error: `no briefing for ${krxDate}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p === "/api/series") {
|
||||||
|
sendJson(res, 200, await buildSeries());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p === "/api/errors") {
|
||||||
|
sendJson(res, 200, await readErrors());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (p.startsWith("/api/")) {
|
||||||
|
sendJson(res, 404, { error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendStatic(res, p);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`request ${p} failed:`, err);
|
||||||
|
sendJson(res, 500, { error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`us2kr dashboard listening on :${PORT} (reports: ${REPORTS_DIR})`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/** Mirrors us2kr/models.py (pydantic model_dump_json → snake_case). */
|
||||||
|
|
||||||
|
export interface Quote {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
close: number | null;
|
||||||
|
prev_close: number | null;
|
||||||
|
change_pct: number | null;
|
||||||
|
change_abs: number | null;
|
||||||
|
day_high: number | null;
|
||||||
|
day_low: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
avg_volume_10d: number | null;
|
||||||
|
after_hours_price: number | null;
|
||||||
|
after_hours_change_pct: number | null;
|
||||||
|
as_of: string | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsItem {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
link: string;
|
||||||
|
source: string;
|
||||||
|
published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EconEvent {
|
||||||
|
name: string;
|
||||||
|
time: string | null;
|
||||||
|
country: string;
|
||||||
|
actual: string | null;
|
||||||
|
forecast: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
importance: "high" | "medium" | "low";
|
||||||
|
kind: "released" | "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EarningsItem {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
report_date: string;
|
||||||
|
timing: "bmo" | "amc" | "unknown";
|
||||||
|
eps_actual: number | null;
|
||||||
|
eps_estimate: number | null;
|
||||||
|
revenue_actual: number | null;
|
||||||
|
revenue_estimate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketPayload {
|
||||||
|
session_date: string;
|
||||||
|
us_close_time: string;
|
||||||
|
generated_at: string;
|
||||||
|
krx_date: string;
|
||||||
|
krx_open_today: boolean;
|
||||||
|
quotes: Quote[];
|
||||||
|
news: NewsItem[];
|
||||||
|
events: EconEvent[];
|
||||||
|
earnings: EarningsItem[];
|
||||||
|
history_notes: string[];
|
||||||
|
data_gaps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
headline: string;
|
||||||
|
market_overview: string;
|
||||||
|
key_drivers: string[];
|
||||||
|
rates_fx_commodities: string;
|
||||||
|
korea_implications: string[];
|
||||||
|
watchlist_today: string[];
|
||||||
|
risk_flags: string[];
|
||||||
|
data_gaps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefingResult {
|
||||||
|
payload: MarketPayload;
|
||||||
|
report: Report | null;
|
||||||
|
markdown: string;
|
||||||
|
model_used: string | null;
|
||||||
|
degraded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- API shapes
|
||||||
|
|
||||||
|
export interface BriefingSummary {
|
||||||
|
krx_date: string;
|
||||||
|
session_date: string;
|
||||||
|
generated_at: string;
|
||||||
|
krx_open_today: boolean;
|
||||||
|
model_used: string | null;
|
||||||
|
degraded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
headline: string | null;
|
||||||
|
quote_count: number;
|
||||||
|
news_count: number;
|
||||||
|
data_gaps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesResponse {
|
||||||
|
/** US session dates, ascending. */
|
||||||
|
sessions: string[];
|
||||||
|
/** symbol → aligned arrays (null where the session had no value). */
|
||||||
|
series: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; group: string; closes: (number | null)[]; change_pct: (number | null)[] }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLine {
|
||||||
|
ts: string;
|
||||||
|
subject: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
ok: boolean;
|
||||||
|
report_count: number;
|
||||||
|
reports_dir: string;
|
||||||
|
latest_krx_date: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>US2KR 대시보드</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>US2KR <span class="topbar-sub">미국 마감 → 한국 개장 브리핑</span></h1>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div id="clocks" class="clocks"></div>
|
||||||
|
<div id="health" class="health" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section id="tiles" class="tiles" aria-label="최신 세션 요약"></section>
|
||||||
|
|
||||||
|
<section class="panel chart-panel" aria-label="시세 추이">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 id="chart-title">시세 추이</h2>
|
||||||
|
<div class="chart-controls">
|
||||||
|
<label class="visually-hidden" for="symbol-select">종목 선택</label>
|
||||||
|
<select id="symbol-select"></select>
|
||||||
|
<button id="table-toggle" type="button" aria-pressed="false">표로 보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chart" class="chart"></div>
|
||||||
|
<div id="chart-table" class="chart-table" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<aside class="panel list-panel" aria-label="브리핑 목록">
|
||||||
|
<h2>브리핑</h2>
|
||||||
|
<ul id="briefing-list" class="briefing-list"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article class="panel report-panel">
|
||||||
|
<div id="report-meta" class="report-meta"></div>
|
||||||
|
<div id="report-summary"></div>
|
||||||
|
<div id="report-body" class="markdown"></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="errors-panel" class="panel errors-panel" hidden>
|
||||||
|
<h2>오류 로그 <span id="errors-count" class="badge badge-error"></span></h2>
|
||||||
|
<ul id="errors-list" class="errors-list"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/* Design tokens - light is the base, dark overrides via prefers-color-scheme. */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page: #f9f9f7;
|
||||||
|
--surface: #fcfcfb;
|
||||||
|
--ink: #0b0b0b;
|
||||||
|
--ink-2: #52514e;
|
||||||
|
--muted: #898781;
|
||||||
|
--grid: #e1e0d9;
|
||||||
|
--baseline: #c3c2b7;
|
||||||
|
--border: rgba(11, 11, 11, 0.1);
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
/* Korean market convention: up = red, down = blue (diverging pair poles). */
|
||||||
|
--delta-up: #d03b3b;
|
||||||
|
--delta-down: #256abf;
|
||||||
|
--badge-warn-bg: #fdf3d7;
|
||||||
|
--badge-warn-ink: #7a5600;
|
||||||
|
--badge-err-bg: #fbe3e3;
|
||||||
|
--badge-err-ink: #8f2020;
|
||||||
|
--badge-ok-bg: #e2f2e2;
|
||||||
|
--badge-ok-ink: #106310;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0d0d0d;
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-2: #c3c2b7;
|
||||||
|
--muted: #898781;
|
||||||
|
--grid: #2c2c2a;
|
||||||
|
--baseline: #383835;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--delta-up: #e66767;
|
||||||
|
--delta-down: #3987e5;
|
||||||
|
--badge-warn-bg: #3a2f10;
|
||||||
|
--badge-warn-ink: #fab219;
|
||||||
|
--badge-err-bg: #3c1a1a;
|
||||||
|
--badge-err-ink: #e66767;
|
||||||
|
--badge-ok-bg: #143214;
|
||||||
|
--badge-ok-ink: #4fc24f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--page);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
h1, h2 { margin: 0; font-weight: 650; }
|
||||||
|
h1 { font-size: 17px; }
|
||||||
|
h2 { font-size: 14px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
margin: -1px; padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.topbar-sub { font-size: 13px; font-weight: 400; color: var(--muted); margin-left: 6px; }
|
||||||
|
.topbar-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||||
|
.clocks { display: flex; gap: 14px; font-size: 12.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
||||||
|
.clock::before { content: "●"; font-size: 7px; vertical-align: 2px; margin-right: 5px; color: var(--series-1); }
|
||||||
|
.health { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 20px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.chart-controls { display: flex; gap: 8px; align-items: center; }
|
||||||
|
select, button {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--baseline);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
}
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button[aria-pressed="true"] { background: var(--grid); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ stat tiles */
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
}
|
||||||
|
.tile-label { font-size: 12px; color: var(--ink-2); }
|
||||||
|
.tile-value { font-size: 21px; font-weight: 650; margin-top: 2px; }
|
||||||
|
.tile-delta { font-size: 12.5px; margin-top: 1px; color: var(--ink-2); }
|
||||||
|
.tile-delta.up { color: var(--delta-up); }
|
||||||
|
.tile-delta.down { color: var(--delta-down); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ chart */
|
||||||
|
.chart { position: relative; }
|
||||||
|
.chart svg { display: block; width: 100%; height: auto; }
|
||||||
|
.chart .axis-label { font-size: 11px; fill: var(--muted); }
|
||||||
|
.chart .gridline { stroke: var(--grid); stroke-width: 1; }
|
||||||
|
.chart .baseline { stroke: var(--baseline); stroke-width: 1; }
|
||||||
|
.chart .series-line { stroke: var(--series-1); stroke-width: 2; fill: none; }
|
||||||
|
.chart .hover-dot { fill: var(--series-1); stroke: var(--surface); stroke-width: 2; }
|
||||||
|
.chart .crosshair { stroke: var(--baseline); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||||
|
.chart-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--baseline);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||||
|
white-space: nowrap;
|
||||||
|
transform: translate(-50%, -110%);
|
||||||
|
}
|
||||||
|
.chart-tooltip .tt-value { font-weight: 650; }
|
||||||
|
.chart-empty { color: var(--muted); padding: 24px 4px; }
|
||||||
|
|
||||||
|
.chart-table { overflow-x: auto; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ columns */
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.briefing-list button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.briefing-list button[aria-current="true"] { border-color: var(--series-1); background: var(--grid); }
|
||||||
|
.briefing-date { font-weight: 650; }
|
||||||
|
.day-closed {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
column-gap: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px dashed var(--grid);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.day-closed .briefing-date { font-weight: 500; white-space: nowrap; }
|
||||||
|
.briefing-line { font-size: 12px; color: var(--ink-2); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-ok { background: var(--badge-ok-bg); color: var(--badge-ok-ink); }
|
||||||
|
.badge-warn { background: var(--badge-warn-bg); color: var(--badge-warn-ink); }
|
||||||
|
.badge-error { background: var(--badge-err-bg); color: var(--badge-err-ink); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ summary card */
|
||||||
|
.summary-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--series-1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--page);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
}
|
||||||
|
.summary-headline { font-size: 16px; font-weight: 700; margin: 0 0 6px; }
|
||||||
|
.summary-overview { margin: 0 0 10px; color: var(--ink-2); }
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.summary-block h3 { font-size: 12.5px; margin: 0 0 4px; color: var(--muted); font-weight: 650; }
|
||||||
|
.summary-block.risk h3 { color: var(--badge-err-ink); }
|
||||||
|
.summary-block ul { margin: 0; padding-left: 18px; }
|
||||||
|
.summary-block li { margin: 2px 0; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ report */
|
||||||
|
.report-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; }
|
||||||
|
.markdown { max-width: 76ch; }
|
||||||
|
.markdown h1 { font-size: 19px; margin: 10px 0 6px; }
|
||||||
|
.markdown h2 { font-size: 15px; color: var(--ink); margin: 18px 0 6px; }
|
||||||
|
.markdown h3 { font-size: 14px; margin: 14px 0 4px; }
|
||||||
|
.markdown p, .markdown li { color: var(--ink); }
|
||||||
|
.markdown a { color: var(--series-1); }
|
||||||
|
.markdown table { border-collapse: collapse; font-size: 13px; margin: 8px 0; display: block; overflow-x: auto; }
|
||||||
|
.markdown th, .markdown td { border: 1px solid var(--grid); padding: 4px 9px; text-align: right; }
|
||||||
|
.markdown th:first-child, .markdown td:first-child { text-align: left; }
|
||||||
|
.markdown th { background: var(--page); color: var(--ink-2); font-weight: 600; }
|
||||||
|
.markdown td { font-variant-numeric: tabular-nums; }
|
||||||
|
.markdown blockquote { margin: 8px 0; padding: 2px 12px; border-left: 3px solid var(--baseline); color: var(--ink-2); }
|
||||||
|
.markdown code { background: var(--page); border: 1px solid var(--grid); border-radius: 4px; padding: 0 4px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
table.data-table { border-collapse: collapse; font-size: 13px; }
|
||||||
|
table.data-table th, table.data-table td { border: 1px solid var(--grid); padding: 4px 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
table.data-table th:first-child, table.data-table td:first-child { text-align: left; }
|
||||||
|
table.data-table th { background: var(--page); color: var(--ink-2); font-weight: 600; }
|
||||||
|
|
||||||
|
.errors-list { list-style: none; margin: 8px 0 0; padding: 0; font-size: 12.5px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.errors-list .err-ts { color: var(--muted); margin-right: 8px; }
|
||||||
|
.errors-list .err-subject { font-weight: 650; color: var(--badge-err-ink); margin-right: 8px; }
|
||||||
|
.errors-list .err-detail { color: var(--ink-2); overflow-wrap: anywhere; }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!-- 로그인 시 Apple container 런타임과 us2kr 컨테이너를 기동하는 one-shot 에이전트.
|
||||||
|
__CONTAINER__/__ROOT__ 는 install-apple-container.sh --boot 가 치환한다. -->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>com.us2kr.container</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>-c</string>
|
||||||
|
<string>__CONTAINER__ system start && __CONTAINER__ start us2kr; __CONTAINER__ start us2kr-dashboard</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>StandardOutPath</key><string>__ROOT__/logs/container-boot.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>__ROOT__/logs/container-boot.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# macOS: Apple container(https://github.com/apple/container)로 빌드·상주 실행.
|
||||||
|
# 실행: bash deploy/install-apple-container.sh # 이미지 빌드 + 컨테이너 (재)기동
|
||||||
|
# bash deploy/install-apple-container.sh --boot # + 로그인 시 자동 기동(LaunchAgent) 등록
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
NAME=us2kr
|
||||||
|
|
||||||
|
CONTAINER_BIN="$(command -v container || true)"
|
||||||
|
if [ -z "$CONTAINER_BIN" ]; then
|
||||||
|
echo "Apple container CLI가 없습니다: brew install container (macOS 15+, Apple Silicon)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ROOT/.env" ]; then
|
||||||
|
echo "$ROOT/.env 가 없습니다: cp .env.example .env 후 MINIMAX_API_KEY를 넣으세요" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DASHBOARD=us2kr-dashboard
|
||||||
|
DASHBOARD_PORT="${US2KR_DASHBOARD_PORT:-8090}"
|
||||||
|
|
||||||
|
mkdir -p "$ROOT/data" "$ROOT/reports" "$ROOT/logs"
|
||||||
|
container system start
|
||||||
|
container build -t "$NAME" -f "$ROOT/deploy/Dockerfile" "$ROOT"
|
||||||
|
container build -t "$DASHBOARD" -f "$ROOT/dashboard/Dockerfile" "$ROOT/dashboard"
|
||||||
|
|
||||||
|
# 기존 컨테이너는 갈아끼운다 (이미지 갱신 반영). container에는 restart 정책이 없어
|
||||||
|
# 자동 기동은 --boot 의 LaunchAgent가 담당한다.
|
||||||
|
container stop "$NAME" "$DASHBOARD" >/dev/null 2>&1 || true
|
||||||
|
container delete "$NAME" "$DASHBOARD" >/dev/null 2>&1 || true
|
||||||
|
container run --detach --name "$NAME" \
|
||||||
|
--env-file "$ROOT/.env" \
|
||||||
|
--env US2KR_DATA_DIR=/app/data \
|
||||||
|
--env US2KR_REPORTS_DIR=/app/reports \
|
||||||
|
--env US2KR_LOG_DIR=/app/logs \
|
||||||
|
--volume "$ROOT/data:/app/data" \
|
||||||
|
--volume "$ROOT/reports:/app/reports" \
|
||||||
|
--volume "$ROOT/logs:/app/logs" \
|
||||||
|
"$NAME"
|
||||||
|
container run --detach --name "$DASHBOARD" \
|
||||||
|
--volume "$ROOT/reports:/data/reports:ro" \
|
||||||
|
--publish "0.0.0.0:$DASHBOARD_PORT:8090" \
|
||||||
|
"$DASHBOARD"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--boot" ]; then
|
||||||
|
PLIST_SRC="$ROOT/deploy/com.us2kr.container.plist"
|
||||||
|
PLIST_DST="$HOME/Library/LaunchAgents/com.us2kr.container.plist"
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents"
|
||||||
|
sed -e "s#__CONTAINER__#$CONTAINER_BIN#g" -e "s#__ROOT__#$ROOT#g" "$PLIST_SRC" > "$PLIST_DST"
|
||||||
|
launchctl bootout "gui/$(id -u)/com.us2kr.container" 2>/dev/null || true
|
||||||
|
launchctl bootstrap "gui/$(id -u)" "$PLIST_DST"
|
||||||
|
echo "boot agent: $PLIST_DST (로그인 시 container system start + container start $NAME $DASHBOARD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "dashboard: http://localhost:$DASHBOARD_PORT"
|
||||||
|
echo "status: container list --all"
|
||||||
|
echo "logs: container logs --follow $NAME (파일: tail -f $ROOT/logs/us2kr.log)"
|
||||||
|
echo "stop: container stop $NAME $DASHBOARD"
|
||||||
|
echo "remove: container stop $NAME $DASHBOARD && container delete $NAME $DASHBOARD"
|
||||||
@@ -192,6 +192,70 @@ def test_build_payload_history_failure_is_a_gap(env):
|
|||||||
assert any(g.startswith("history:") for g in payload.data_gaps)
|
assert any(g.startswith("history:") for g in payload.data_gaps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_payload_skips_searx_when_not_configured(env):
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise AssertionError("fetch_searx_news must not be called without SEARXNG_BASE_URL")
|
||||||
|
|
||||||
|
env.monkeypatch.setattr(pipeline, "fetch_searx_news", boom)
|
||||||
|
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
|
||||||
|
assert payload.data_gaps == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_payload_merges_searx_news(monkeypatch, tmp_path):
|
||||||
|
env = Env(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
SEARXNG_BASE_URL="http://searx.local:8888",
|
||||||
|
US2KR_SEARXNG_QUERIES="markets, fed",
|
||||||
|
US2KR_NEWS_MAX_ITEMS="3",
|
||||||
|
)
|
||||||
|
rss = sample_news() # 1 item, published 17:00 ET
|
||||||
|
searx_items = [
|
||||||
|
NewsItem( # duplicate of the RSS story by link; same timestamp -> RSS copy wins the tie
|
||||||
|
title="Stocks rally (search copy)",
|
||||||
|
link="https://example.com/1#utm",
|
||||||
|
source="Reuters",
|
||||||
|
published=datetime(2026, 8, 25, 17, 0, tzinfo=ET),
|
||||||
|
),
|
||||||
|
NewsItem(
|
||||||
|
title="Chip stocks jump",
|
||||||
|
link="https://example.com/chips",
|
||||||
|
source="Barron's",
|
||||||
|
published=datetime(2026, 8, 25, 18, 30, tzinfo=ET),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def fetch_searx_news(base_url, since, queries=None, **kwargs):
|
||||||
|
env.calls["searxng"] = env.calls.get("searxng", 0) + 1
|
||||||
|
assert base_url == "http://searx.local:8888"
|
||||||
|
assert queries == ["markets", "fed"]
|
||||||
|
assert since == env.news_since
|
||||||
|
return searx_items
|
||||||
|
|
||||||
|
env.monkeypatch.setattr(pipeline, "fetch_searx_news", fetch_searx_news)
|
||||||
|
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
|
||||||
|
|
||||||
|
assert env.calls["searxng"] == 1
|
||||||
|
assert [n.title for n in payload.news] == ["Chip stocks jump", "Stocks rally"]
|
||||||
|
assert payload.data_gaps == []
|
||||||
|
# the archived news blob is the merged list
|
||||||
|
assert "Chip stocks jump" in env.store.raw[(SESSION, "news")]
|
||||||
|
assert rss[0].title in env.store.raw[(SESSION, "news")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_payload_searx_failure_keeps_rss_news(monkeypatch, tmp_path):
|
||||||
|
env = Env(monkeypatch, tmp_path, SEARXNG_BASE_URL="http://searx.local:8888")
|
||||||
|
|
||||||
|
def boom(base_url, since, queries=None, **kwargs):
|
||||||
|
raise RuntimeError("all searxng queries failed: markets: HTTPError")
|
||||||
|
|
||||||
|
env.monkeypatch.setattr(pipeline, "fetch_searx_news", boom)
|
||||||
|
payload = pipeline.build_payload(SESSION, env.settings, env.clock, env.store)
|
||||||
|
|
||||||
|
assert [n.title for n in payload.news] == ["Stocks rally"]
|
||||||
|
assert any(g.startswith("searxng: RuntimeError") for g in payload.data_gaps)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------------- run_briefing
|
# -------------------------------------------------------------------------------- run_briefing
|
||||||
def test_run_briefing_happy_path_saves_then_delivers(env):
|
def test_run_briefing_happy_path_saves_then_delivers(env):
|
||||||
result = pipeline.run_briefing(SESSION, env.settings)
|
result = pipeline.run_briefing(SESSION, env.settings)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def test_markdown_header_and_degraded_mode():
|
|||||||
assert "🧩 축소 모드(LLM 요약 없음)" in md
|
assert "🧩 축소 모드(LLM 요약 없음)" in md
|
||||||
assert "## 총평" not in md and "## 시장 개요" not in md
|
assert "## 총평" not in md and "## 시장 개요" not in md
|
||||||
assert "## 📊 주요 지수" in md # tables still present
|
assert "## 📊 주요 지수" in md # tables still present
|
||||||
assert md.rstrip().endswith("요약 모델: 없음")
|
assert "요약 모델" not in md # the model is an implementation detail, never displayed
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_with_report_sections_and_numbers_from_payload():
|
def test_markdown_with_report_sections_and_numbers_from_payload():
|
||||||
@@ -132,7 +132,7 @@ def test_markdown_with_report_sections_and_numbers_from_payload():
|
|||||||
# numeric table values come from the payload
|
# numeric table values come from the payload
|
||||||
assert "| S&P 500 | 6,432.10 | +0.52% |" in md
|
assert "| S&P 500 | 6,432.10 | +0.52% |" in md
|
||||||
assert "| VIX | 15.20 | -5.00% |" in md
|
assert "| VIX | 15.20 | -5.00% |" in md
|
||||||
assert md.rstrip().endswith("요약 모델: MiniMax-M3")
|
assert "요약 모델" not in md
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_group_order_and_sections():
|
def test_markdown_group_order_and_sections():
|
||||||
@@ -333,7 +333,6 @@ def test_telegram_full_briefing_roundtrip(limit: int):
|
|||||||
assert 0 < len(c) <= limit
|
assert 0 < len(c) <= limit
|
||||||
_assert_balanced(c)
|
_assert_balanced(c)
|
||||||
joined = "\n".join(chunks)
|
joined = "\n".join(chunks)
|
||||||
assert "요약 모델: MiniMax-M3" in joined
|
|
||||||
assert "미 10년물" in joined and "+3.5bp" in joined
|
assert "미 10년물" in joined and "+3.5bp" in joined
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for us2kr.collectors.searx (no network: requests.get is mocked)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from us2kr.collectors import searx
|
||||||
|
from us2kr.collectors.searx import _parse_published, fetch_searx_news
|
||||||
|
|
||||||
|
SINCE = datetime(2026, 8, 25, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(title: str, url: str, published: str | None = None, **extra) -> dict:
|
||||||
|
result = {"title": title, "url": url, "content": extra.pop("content", "")}
|
||||||
|
if published is not None:
|
||||||
|
result["publishedDate"] = published
|
||||||
|
result.update(extra)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, results: list | None, status: int = 200, body: str | None = None):
|
||||||
|
self.status_code = status
|
||||||
|
self._body = body if body is not None else json.dumps({"results": results})
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise requests.HTTPError(f"{self.status_code} error")
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return json.loads(self._body)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(dt: datetime) -> str:
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_published_variants():
|
||||||
|
assert _parse_published("2026-08-25T14:00:00+00:00") == datetime(2026, 8, 25, 14, 0, tzinfo=UTC)
|
||||||
|
assert _parse_published("2026-08-25T14:00:00Z") == datetime(2026, 8, 25, 14, 0, tzinfo=UTC)
|
||||||
|
# naive -> assumed UTC
|
||||||
|
assert _parse_published("2026-08-25T14:00:00") == datetime(2026, 8, 25, 14, 0, tzinfo=UTC)
|
||||||
|
assert _parse_published(None) is None
|
||||||
|
assert _parse_published("") is None
|
||||||
|
assert _parse_published("yesterday-ish") is None
|
||||||
|
assert _parse_published(1234) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_filters_maps_and_floors_undated(mocker):
|
||||||
|
fresh = SINCE + timedelta(hours=2)
|
||||||
|
results = [
|
||||||
|
_result("Fed pauses", "https://n.example/fed", _iso(fresh), content="<p>Held & steady</p>", source="Reuters"),
|
||||||
|
_result("Old story", "https://n.example/old", _iso(SINCE - timedelta(hours=3))),
|
||||||
|
_result("Undated wrap", "https://n.example/wrap", None, engine="bing news"),
|
||||||
|
_result("", "https://n.example/notitle", _iso(fresh)),
|
||||||
|
_result("No link", "", _iso(fresh)),
|
||||||
|
]
|
||||||
|
get = mocker.patch("us2kr.collectors.searx.requests.get", return_value=_Resp(results))
|
||||||
|
|
||||||
|
items = fetch_searx_news("http://searx.local:8888", since=SINCE, queries=["markets"])
|
||||||
|
|
||||||
|
assert [i.title for i in items] == ["Fed pauses", "Undated wrap"]
|
||||||
|
assert items[0].summary == "Held & steady"
|
||||||
|
assert items[0].source == "Reuters"
|
||||||
|
assert items[0].published == fresh
|
||||||
|
# undated: kept, timestamped at the window floor, engine used as source fallback
|
||||||
|
assert items[1].published == SINCE
|
||||||
|
assert items[1].source == "bing news"
|
||||||
|
|
||||||
|
params = get.call_args.kwargs["params"]
|
||||||
|
assert params["format"] == "json"
|
||||||
|
assert params["categories"] == "news"
|
||||||
|
assert params["time_range"] == "day"
|
||||||
|
assert get.call_args.args[0] == "http://searx.local:8888/search"
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_since_is_treated_as_utc(mocker):
|
||||||
|
fresh = SINCE + timedelta(hours=1)
|
||||||
|
mocker.patch(
|
||||||
|
"us2kr.collectors.searx.requests.get",
|
||||||
|
return_value=_Resp([_result("A", "https://n.example/a", _iso(fresh))]),
|
||||||
|
)
|
||||||
|
items = fetch_searx_news("http://searx.local:8888", since=SINCE.replace(tzinfo=None), queries=["q"])
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].published.tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_across_queries_and_max_per_query(mocker):
|
||||||
|
fresh = SINCE + timedelta(hours=1)
|
||||||
|
per_query = {
|
||||||
|
"q1": _Resp(
|
||||||
|
[
|
||||||
|
_result("Story A", "https://n.example/a/", _iso(fresh)),
|
||||||
|
_result("Story B", "https://n.example/b", _iso(fresh)),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
# same article as q1's Story A modulo trailing slash / fragment
|
||||||
|
"q2": _Resp([_result("Story A again", "https://n.example/a#frag", _iso(fresh))]),
|
||||||
|
}
|
||||||
|
mocker.patch(
|
||||||
|
"us2kr.collectors.searx.requests.get",
|
||||||
|
side_effect=lambda url, params, **kw: per_query[params["q"]],
|
||||||
|
)
|
||||||
|
items = fetch_searx_news("http://searx.local:8888", since=SINCE, queries=["q1", "q2"], max_per_query=1)
|
||||||
|
# max_per_query=1 keeps only Story A from q1; q2's duplicate link is dropped
|
||||||
|
assert [i.title for i in items] == ["Story A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_failure_is_tolerated(mocker, caplog):
|
||||||
|
fresh = SINCE + timedelta(hours=1)
|
||||||
|
per_query = {
|
||||||
|
"bad": _Resp(None, status=500),
|
||||||
|
"good": _Resp([_result("Kept", "https://n.example/k", _iso(fresh))]),
|
||||||
|
}
|
||||||
|
mocker.patch(
|
||||||
|
"us2kr.collectors.searx.requests.get",
|
||||||
|
side_effect=lambda url, params, **kw: per_query[params["q"]],
|
||||||
|
)
|
||||||
|
items = fetch_searx_news("http://searx.local:8888", since=SINCE, queries=["bad", "good"])
|
||||||
|
assert [i.title for i in items] == ["Kept"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_queries_failing_raises(mocker):
|
||||||
|
mocker.patch("us2kr.collectors.searx.requests.get", return_value=_Resp(None, status=502))
|
||||||
|
with pytest.raises(RuntimeError, match="all searxng queries failed"):
|
||||||
|
fetch_searx_news("http://searx.local:8888", since=SINCE, queries=["a", "b"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_counts_as_failure(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
"us2kr.collectors.searx.requests.get",
|
||||||
|
return_value=_Resp(None, body=json.dumps({"unexpected": True})),
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="all searxng queries failed"):
|
||||||
|
fetch_searx_news("http://searx.local:8888", since=SINCE, queries=["only"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_queries_used_when_none(mocker):
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def record(url, params, **kw):
|
||||||
|
calls.append(params["q"])
|
||||||
|
return _Resp([])
|
||||||
|
|
||||||
|
mocker.patch("us2kr.collectors.searx.requests.get", side_effect=record)
|
||||||
|
assert fetch_searx_news("http://searx.local:8888", since=SINCE) == []
|
||||||
|
assert calls == searx.DEFAULT_QUERIES
|
||||||
+18
-12
@@ -125,6 +125,23 @@ def _parse_feed(source: str, content: bytes, since: datetime) -> list[NewsItem]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def dedup_news(items: list[NewsItem]) -> list[NewsItem]:
|
||||||
|
"""Newest first, deduplicated by normalised title and by link (fragment/trailing slash ignored)."""
|
||||||
|
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(items, 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)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def fetch_news(since: datetime, max_items: int = 40) -> list[NewsItem]:
|
def fetch_news(since: datetime, max_items: int = 40) -> list[NewsItem]:
|
||||||
"""Collect headlines published at/after `since` from all feeds, deduplicated, newest first.
|
"""Collect headlines published at/after `since` from all feeds, deduplicated, newest first.
|
||||||
|
|
||||||
@@ -151,18 +168,7 @@ def fetch_news(since: datetime, max_items: int = 40) -> list[NewsItem]:
|
|||||||
if failures and len(failures) == len(FEEDS):
|
if failures and len(failures) == len(FEEDS):
|
||||||
raise RuntimeError("all news feeds failed: " + "; ".join(failures))
|
raise RuntimeError("all news feeds failed: " + "; ".join(failures))
|
||||||
|
|
||||||
seen_titles: set[str] = set()
|
unique = dedup_news(collected)
|
||||||
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(
|
log.info(
|
||||||
"news: %d raw -> %d unique, returning %d (feeds failed: %d/%d)",
|
"news: %d raw -> %d unique, returning %d (feeds failed: %d/%d)",
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""SearXNG news collector: supplements the RSS feeds with meta-search results.
|
||||||
|
|
||||||
|
Used only when ``SEARXNG_BASE_URL`` is configured. Each query is fetched independently so that
|
||||||
|
one failing query never spoils the others. Only if *every* query fails does
|
||||||
|
`fetch_searx_news` raise. The instance must have the JSON output format enabled
|
||||||
|
(searxng settings.yml: ``search.formats`` includes ``json``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from us2kr.collectors.news import USER_AGENT, strip_html
|
||||||
|
from us2kr.models import NewsItem
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEARX_TIMEOUT_SECONDS = 15.0
|
||||||
|
MAX_PER_QUERY = 15
|
||||||
|
|
||||||
|
# Complements the fixed RSS feeds: broad market wrap + macro + the Korea/semis angle the
|
||||||
|
# briefing cares about. Override with US2KR_SEARXNG_QUERIES (CSV).
|
||||||
|
DEFAULT_QUERIES: list[str] = [
|
||||||
|
"US stock market",
|
||||||
|
"Federal Reserve",
|
||||||
|
"semiconductor stocks",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_published(raw: object) -> datetime | None:
|
||||||
|
"""ISO-8601 publishedDate -> tz-aware UTC datetime, or None when absent/garbled."""
|
||||||
|
if not raw or not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_query(base_url: str, query: str) -> list[dict]:
|
||||||
|
resp = requests.get(
|
||||||
|
urljoin(base_url.rstrip("/") + "/", "search"),
|
||||||
|
params={"q": query, "format": "json", "categories": "news", "time_range": "day"},
|
||||||
|
timeout=SEARX_TIMEOUT_SECONDS,
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
results = resp.json().get("results")
|
||||||
|
if not isinstance(results, list): # malformed server payload, not a caller type error
|
||||||
|
raise ValueError("malformed SearXNG response: no results list") # noqa: TRY004
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_searx_news(
|
||||||
|
base_url: str,
|
||||||
|
since: datetime,
|
||||||
|
queries: list[str] | None = None,
|
||||||
|
max_per_query: int = MAX_PER_QUERY,
|
||||||
|
) -> list[NewsItem]:
|
||||||
|
"""Search the news category for each query, returning items published at/after `since`.
|
||||||
|
|
||||||
|
Results without a publishedDate are kept (time_range=day already bounds their age) but
|
||||||
|
timestamped at `since`, so they sort last and never win deduplication against a dated copy.
|
||||||
|
Raises RuntimeError only when every query failed.
|
||||||
|
"""
|
||||||
|
if since.tzinfo is None:
|
||||||
|
since = since.replace(tzinfo=UTC)
|
||||||
|
else:
|
||||||
|
since = since.astimezone(UTC)
|
||||||
|
|
||||||
|
queries = queries or DEFAULT_QUERIES
|
||||||
|
collected: list[NewsItem] = []
|
||||||
|
failures: list[str] = []
|
||||||
|
seen_links: set[str] = set() # the same article routinely matches several queries
|
||||||
|
for query in queries:
|
||||||
|
try:
|
||||||
|
results = _fetch_query(base_url, query)
|
||||||
|
except Exception as exc: # noqa: BLE001 - per-query isolation
|
||||||
|
log.warning("searxng query %r failed: %s: %s", query, type(exc).__name__, str(exc)[:200])
|
||||||
|
failures.append(f"{query}: {type(exc).__name__}")
|
||||||
|
continue
|
||||||
|
kept = undated = 0
|
||||||
|
for result in results:
|
||||||
|
title = (result.get("title") or "").strip()
|
||||||
|
link = (result.get("url") or "").strip()
|
||||||
|
if not title or not link:
|
||||||
|
continue
|
||||||
|
lkey = link.split("#", 1)[0].rstrip("/").lower()
|
||||||
|
if lkey in seen_links:
|
||||||
|
continue
|
||||||
|
published = _parse_published(result.get("publishedDate"))
|
||||||
|
if published is None:
|
||||||
|
undated += 1
|
||||||
|
published = since
|
||||||
|
elif published < since:
|
||||||
|
continue
|
||||||
|
seen_links.add(lkey)
|
||||||
|
collected.append(
|
||||||
|
NewsItem(
|
||||||
|
title=title,
|
||||||
|
summary=strip_html(result.get("content") or ""),
|
||||||
|
link=link,
|
||||||
|
source=result.get("source") or result.get("engine") or "SearXNG",
|
||||||
|
published=published,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
kept += 1
|
||||||
|
if kept >= max_per_query:
|
||||||
|
break
|
||||||
|
log.info("searxng query %r: %d kept (%d undated) of %d results", query, kept, undated, len(results))
|
||||||
|
|
||||||
|
if failures and len(failures) == len(queries):
|
||||||
|
raise RuntimeError("all searxng queries failed: " + "; ".join(failures))
|
||||||
|
return collected
|
||||||
+8
-1
@@ -38,6 +38,13 @@ class Settings(BaseSettings):
|
|||||||
# --- Optional data keys ---
|
# --- Optional data keys ---
|
||||||
fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY")
|
fred_api_key: str | None = Field(default=None, alias="FRED_API_KEY")
|
||||||
|
|
||||||
|
# --- SearXNG (optional news search; instance must enable the json output format) ---
|
||||||
|
searxng_base_url: str | None = Field(default=None, alias="SEARXNG_BASE_URL")
|
||||||
|
# Empty -> collector defaults (us2kr.collectors.searx.DEFAULT_QUERIES).
|
||||||
|
searxng_queries: Annotated[list[str], NoDecode] = Field(
|
||||||
|
default_factory=list, alias="US2KR_SEARXNG_QUERIES"
|
||||||
|
)
|
||||||
|
|
||||||
# --- Paths ---
|
# --- Paths ---
|
||||||
data_dir: Path = Field(default=Path("data"), alias="US2KR_DATA_DIR")
|
data_dir: Path = Field(default=Path("data"), alias="US2KR_DATA_DIR")
|
||||||
reports_dir: Path = Field(default=Path("reports"), alias="US2KR_REPORTS_DIR")
|
reports_dir: Path = Field(default=Path("reports"), alias="US2KR_REPORTS_DIR")
|
||||||
@@ -60,7 +67,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL")
|
log_level: str = Field(default="INFO", alias="US2KR_LOG_LEVEL")
|
||||||
|
|
||||||
@field_validator("minimax_fallback_models", "krx_extra_holidays", mode="before")
|
@field_validator("minimax_fallback_models", "krx_extra_holidays", "searxng_queries", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _split_csv(cls, v):
|
def _split_csv(cls, v):
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
|
|||||||
+16
-1
@@ -16,8 +16,9 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from us2kr.collectors.base import run_safely
|
from us2kr.collectors.base import run_safely
|
||||||
from us2kr.collectors.events import fetch_earnings, fetch_events
|
from us2kr.collectors.events import fetch_earnings, fetch_events
|
||||||
from us2kr.collectors.news import fetch_news
|
from us2kr.collectors.news import dedup_news, fetch_news
|
||||||
from us2kr.collectors.prices import fetch_quotes
|
from us2kr.collectors.prices import fetch_quotes
|
||||||
|
from us2kr.collectors.searx import fetch_searx_news
|
||||||
from us2kr.config import KST, Settings
|
from us2kr.config import KST, Settings
|
||||||
from us2kr.deliver import build_deliverers
|
from us2kr.deliver import build_deliverers
|
||||||
from us2kr.llm.base import LLMError
|
from us2kr.llm.base import LLMError
|
||||||
@@ -79,6 +80,20 @@ def build_payload(session: date, settings: Settings, clock: MarketClock, store:
|
|||||||
news = run_safely(
|
news = run_safely(
|
||||||
"news", lambda: fetch_news(since=news_since, max_items=settings.news_max_items), [], gaps
|
"news", lambda: fetch_news(since=news_since, max_items=settings.news_max_items), [], gaps
|
||||||
)
|
)
|
||||||
|
if settings.searxng_base_url:
|
||||||
|
searx_news = run_safely(
|
||||||
|
"searxng",
|
||||||
|
lambda: fetch_searx_news(
|
||||||
|
settings.searxng_base_url, # type: ignore[arg-type]
|
||||||
|
since=news_since,
|
||||||
|
queries=settings.searxng_queries or None,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
gaps,
|
||||||
|
)
|
||||||
|
if searx_news:
|
||||||
|
# RSS first so it wins timestamp ties in dedup; cap stays at news_max_items overall.
|
||||||
|
news = dedup_news([*news, *searx_news])[: settings.news_max_items]
|
||||||
events = run_safely("events", lambda: fetch_events(session, settings.fred_api_key), [], gaps)
|
events = run_safely("events", lambda: fetch_events(session, settings.fred_api_key), [], gaps)
|
||||||
tickers = earnings_tickers()
|
tickers = earnings_tickers()
|
||||||
earnings = run_safely("earnings", lambda: fetch_earnings(session, tickers), [], gaps)
|
earnings = run_safely("earnings", lambda: fetch_earnings(session, tickers), [], gaps)
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ def render_markdown(payload: MarketPayload, report: Report | None, model_used: s
|
|||||||
ev_lines = [_event_line(e) for e in payload.events] + [_earnings_line(e) for e in payload.earnings]
|
ev_lines = [_event_line(e) for e in payload.events] + [_earnings_line(e) for e in payload.earnings]
|
||||||
lines += _section("경제지표·실적", ev_lines)
|
lines += _section("경제지표·실적", ev_lines)
|
||||||
lines += _section("수집 누락", _bullets(payload.data_gaps))
|
lines += _section("수집 누락", _bullets(payload.data_gaps))
|
||||||
lines += ["---", f"요약 모델: {model_used or '없음'}"]
|
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user