Compare commits

..

No commits in common. "feat/grok" and "main" have entirely different histories.

24 changed files with 400 additions and 7652 deletions

1
.gitignore vendored
View File

@ -4,5 +4,4 @@ node_modules/
.DS_Store .DS_Store
data.db data.db
data.db-* data.db-*
archive/
.gstack/ .gstack/

470
README.md
View File

@ -1,451 +1,133 @@
# MacroScope — 總經儀表板 · 學習 · 個股 · 復盤 · 日曆 # MacroScope — 總經指標儀表板
一個給初學者用的美國總體經濟 + 投資學習工具箱。資料主要來自美國聖路易聯儲 **FRED**免費、公開、Yahoo Finance、SEC EDGAR 等官方與公開來源。全中文介面、每張卡片與檢查都有白話解釋,並用透明公式算出「總經健康分數」。 一個給初學者看的美國總體經濟儀表板。資料來自美國聖路易聯儲的 **FRED**(免費、公開),
全中文介面、每張卡片都有白話解釋,並用透明公式算出「總經健康分數」。
強調「照問題學、不要硬背名詞」:從總經水位、財報健檢、六層投資地圖、交易復盤等實際任務出發,隨時可跳轉回對應的投資原則與名詞。內建市場日曆(含自訂追蹤財報)與可帶頁面上下文的 AI 助手(支援 Grok 與 OpenCode Go > 為什麼需要一個後端FRED 官方 API 不允許瀏覽器直接呼叫(沒有 CORS而且金鑰不能放在前端外洩。
> 所以這裡用一支很小的 Node 伺服器當「代理」:金鑰只留在伺服器,瀏覽器只跟自己的 `/api/macro` 溝通。
> **為什麼需要後端?** FRED 官方 API 不允許瀏覽器直接呼叫(沒有 CORS且金鑰不能放在前端。本專案用一支很小的 Node 伺服器當代理:金鑰只留在伺服器,瀏覽器只跟自己的 `/api/*` 溝通。
--- ---
## 目錄 ## 三步驟啟動
- [快速開始](#快速開始) ### 1. 申請免費的 FRED 金鑰(約 1 分鐘)
- [主要功能](#主要功能) <https://fred.stlouisfed.org/docs/api/api_key.html> 註冊帳號,取得一組 32 碼的金鑰。免費、即時核發。
- [專案在整體 repo 中的位置](#專案在整體-repo-中的位置)
- [資料夾結構](#資料夾結構)
- [系統架構](#系統架構)
- [API 端點](#api-端點)
- [資料流與快取](#資料流與快取)
- [建立知識庫](#建立知識庫)
- [環境變數](#環境變數)
- [技術棧](#技術棧)
- [指標與資料來源](#指標與資料來源)
- [常見問題](#常見問題)
- [免責聲明](#免責聲明)
---
## 快速開始
### 1. 申請 FRED 金鑰(約 1 分鐘)
<https://fred.stlouisfed.org/docs/api/api_key.html> 註冊,取得 32 碼金鑰(免費、即時核發)。
### 2. 設定金鑰 ### 2. 設定金鑰
可以直接啟動後到頂部的 **AI 設定** 頁填入 FRED、OpenCode Go、Grok 等 API key頁面會把金鑰寫入本機專案的 `.env`金鑰不會放在瀏覽器前端。AI provider 填好 key 後可按「抓取模型」,系統會向 provider 的 `/models` 端點取得你帳號實際可用的 model。
啟動後到頂部 **AI 設定** 頁填入: 若想手動建立,也可以把範例檔複製成 `.env` 再填入:
| 變數 | 用途 |
|------|------|
| `FRED_API_KEY` | 總經 + 部分日曆 |
| `GROK_API_KEY` / `OPENCODE_GO_API_KEY` | AI 助手(可擇一或都填) |
| `AI_ACTIVE_PROVIDER` | 預設 provider`grok` 或 `opencode-go` |
設定會寫入本機 `.env`**不會出現在瀏覽器**。
或手動複製範例檔:
```bash ```bash
cp .env.example .env cp .env.example .env
# 編輯 .env 填入金鑰 # 然後編輯 .env把需要的 API key 換成你的金鑰
``` ```
### 3. 安裝並啟動 ### 3. 安裝並啟動
```bash ```bash
npm install npm install
npm run build:knowledge # 學習教材前置(需 emmy 知識庫,見下方)
npm start npm start
# 開發模式自動重啟npm run dev
``` ```
瀏覽器開啟 `http://localhost:3000`。看到 `MacroScope 已啟動` 即成功 看到 `MacroScope 已啟動 → http://localhost:3000` 後,用瀏覽器打開該網址即可。
> 還沒設 FRED 金鑰也能啟動,總經頁會顯示設定教學 > 還沒設定金鑰也能啟動,畫面會直接顯示設定教學,照著做即可
--- ---
## 主要功能 ## Emmy 投資台:四大模組
頂部分頁使用 **hash 路由**(無前端框架 頂部分頁可切換四個視圖(用 hash 路由:`#/`、`#/learn`、`#/stock`、`#/journal`
| 路由 | 視圖 | 說明 | - **總經**:原本的 FRED 總經儀表板。
|------|------|------| - **學習教材**:把 `emmy/` 知識庫學習分類、案例、110 條投資心法、538 個名詞、218 家公司、單集筆記)整理成可瀏覽、可站內跳轉、可搜尋的教材,含互動式練習題庫。
| `#/` | 總經儀表板 | 31 項 FRED 指標、健康分數、殖利率曲線 | - **個股工具**:輸入一個股票代號,四個子分頁共用:
| `#/calendar` | 市場日曆 | FOMC/CPI/非農等 + 自訂追蹤財報 | - **價格走勢**:收盤線圖,可切 3 月1 年5 年/全部等區間。
| `#/learn` | 學習教材 | Obsidian 知識庫快照、wikilink、知識圖譜 | - **財報健檢**自動抓真實財報SEC EDGAR 為主、Yahoo 為輔),照「財報基本功」五步驟給紅綠燈,每條檢查連回名詞/心法。
| `#/stock` | 個股工具 | 財報、走勢、健檢、投資地圖、回測 | - **投資地圖**:把「投資底層邏輯」六層漏斗(總經→產業→商業模式→管理層→估值→交易紀律)做成互動判斷流程:逐層用「是/不確定/否」作答,閘門題答否該層即「出局」,每題連到對應心法,底部彙整總結論並可一鍵「存成交易紀錄」。
| `#/journal` | 交易復盤 | 交易紀錄、損益、勝率、分組統計 | - **回測**:機械式策略回測(買進持有/定期定額/均線趨勢/逢大跌進場),畫策略 vs 買進持有兩條權益曲線並列出總報酬、年化、最大回撤、在場比例、勝率等統計。
| `#/settings` | AI 設定 | 金鑰、provider、模型清單 | - **交易復盤**:手動記錄進出與理由,自動算已實現損益、勝率、賺賠比,並依「交易/投資」「是否犯錯」「依據心法」分組復盤。資料存在本機 `data.db`
右下角常駐 **AI 助手**,會依目前頁面自動帶入上下文 > 個股工具與財報健檢、回測皆會把抓到的資料存進 `data.db` 快取:歷史股價日線預設 6 小時內沿用、財報季報依「軟/硬 TTL + SEC 申報探針」判斷是否需重抓,盡量節省外部 API。所有工具僅供學習**不構成投資建議**。價格/回測資料來源以 Yahoo 為主被限流429時自動改用 Nasdaq 免金鑰歷史(美股最完整)
### 總經儀表板 ### 建立知識庫(學習教材的前置步驟)
- 31 項真實 FRED 指標,分 6 組(利率 & 殖利率、通膨、勞動、成長、貨幣 & 信用、市場情緒 & 大宗)。 學習教材與財報健檢的連結,來自 `emmy/` 的內容快照。`emmy/` 在 `web/` 之外,所以用建置腳本產生 `data/knowledge.json``data/notes.json`
- 每張卡片:最新值、變動%、sparkline、三段白話解釋、資料來源與更新頻率。
- **總經健康分數**0100與景氣燈號附完整 breakdown。
- 殖利率曲線 + 歷史事件標記(`EVENTS` / `EPISODES`)。
- 點卡片開走勢大圖 Modal支援多區間與分數累積走勢。
### 市場日曆
- 未來約 60 天重要事件(央行、通膨、就業、四巫日、休市等)。
- **追蹤財報**:自訂股票代號 watchlistearnings 顯示在對應日期。
- 多源抓取(官方 iCal、FRED、BLS、BEA、Nasdaq伺服器每日快取。
### 學習教材
- 由 `emmy/emmy` Obsidian 知識庫建置快照(原則、案例、名詞、公司、單集、知識圖譜)。
- wikilink `[[目標]]` 站內跳轉、Markdown + Mermaid、個人筆記localStorage
- 與總經、個股、復盤雙向連結。
### 個股工具
- **指標面板**報價、三表、比率、Company Intel。
- **價格走勢**多區間日週月線圖Yahoo 主力Nasdaq 備援)。
- **財報健檢**:五步驟紅黃綠燈 + 連結知識庫。
- **投資地圖**:六層漏斗互動流程。
- **回測**:買進持有 / 定期定額 / 均線 / 逢大跌等策略。
### 交易復盤
- CRUD 交易紀錄自動損益、勝率、Payoff、分組分析。
- 資料存於 SQLite `trades` 表。
### AI 助手
- ProviderGrok、OpenCode Go可從 `/api/ai/models` 抓取可用模型。
- `/api/ai/context` 組裝目前頁面精簡摘要 → `/api/ai/chat` 轉發 LLM。
- 繁體中文、先結論再依據,一律附學習免責聲明。
---
## 專案在整體 repo 中的位置
本目錄是 `finance` monorepo 下的 **Web 應用**;學習內容來自同層的 Obsidian 知識庫:
```
finance/ # 父目錄git repo 根目錄)
├── emmy/
│ └── emmy/ # Obsidian 投資知識庫Markdown + wikilink
│ ├── 投資原則/
│ ├── 名詞/
│ └── ...
└── web/ # ← 本專案MacroScope
├── server.js
├── index.html
├── app.js
├── lib/
├── data/ # build:knowledge 產物
└── data.db # 執行期 SQLite.gitignore
```
建置腳本預期路徑:`../../emmy/emmy`(相對 `web/scripts/`)。
---
## 資料夾結構
```
web/
├── index.html # 總經視圖 HTML 骨架 + 內聯渲染卡片、Modal、圖表
├── app.js # SPA 路由、五視圖 init、AI widget、Markdown/wikilink
├── app.css # 全站樣式(淺色、玻璃擬態 header
├── server.js # Express靜態檔 + 全部 /api + 快取 + AI proxy + .env 管理
├── lib/ # 領域邏輯(多數檔案開頭有職責說明)
│ ├── indicators.js # 31 指標字典 + 6 分組 + 卡片 tip
│ ├── fred.js # FRED 抓取、YoY/MoM、sparkline、殖利率曲線
│ ├── score.js # 健康分數、regime、breakdown、signals
│ ├── context.js # 依歷史序列產生白話脈絡
│ ├── events.js # 歷史事件 EVENTS、危機案例 EPISODES
│ ├── db.js # SQLitecache / series / score_history / trades
│ ├── marketdata.js # Yahoo chart + Nasdaq 備援
│ ├── fundamentals.js # Yahoo quoteSummary + SEC EDGAR 正規化三表
│ ├── fincheck.js # 財報基本功五步驟規則引擎
│ ├── backtest.js # 四種策略回測與績效統計
│ ├── investmap.js # 六層投資地圖設定與問題
│ ├── calendar.js # 日曆事件彙整(多源)
│ ├── calendar-fred.js # FRED 相關日曆源
│ ├── calendar-market.js # 市場結構(四巫、休市等)
│ ├── calendar-i18n.js # 事件中文化
│ ├── calendar-cache.js # 日曆每日快取 + watchlist
│ ├── companyintel.js # 管理層、內部人、新聞等
│ ├── knowledge.js # 載入 knowledge.json / notes.json
│ ├── graph.js # wikilink → vis-network 圖譜
│ ├── learn-html.js # 學習路徑首頁渲染
│ └── glossary.js # 名詞提示
├── data/ # 建置產物(可 commit JSON執行期讀取
│ ├── knowledge.json # 課綱索引 + linkMap + 輕量條目
│ └── notes.json # 全筆記全文kind:id → body
├── scripts/
│ └── build-knowledge.mjs # 從 emmy/emmy 快照 → data/*.json
├── package.json
├── .env.example
├── .gitignore # 忽略 node_modules、.env、data.db
└── README.md
```
**執行期產生、不進版控:**
| 檔案 | 說明 |
|------|------|
| `data.db` | SQLite快取、序列、分數歷史、交易、通用 JSON cache |
| `.env` | API 金鑰與設定(可由設定頁寫入) |
| `node_modules/` | npm 依賴 |
---
## 系統架構
### 分層概覽
```mermaid
flowchart TB
subgraph Browser["瀏覽器Vanilla JS SPA"]
HTML[index.html]
APP[app.js + app.css]
HASH["Hash 路由 #/macro#/settings"]
AIW[AI 浮動面板]
end
subgraph Server["Node.js + Expressserver.js"]
API["/api/* 路由"]
CACHE[記憶體 + SQLite 快取]
ENV[.env 讀寫]
end
subgraph Lib["lib/ 領域模組"]
FRED[fred + indicators + score]
MKT[marketdata + fundamentals]
CAL[calendar-*]
LEARN[knowledge + graph]
TRD[db trades + fincheck + backtest]
end
subgraph External["外部資料源"]
FREDAPI[FRED API]
YAHOO[Yahoo Finance]
SEC[SEC EDGAR]
LLM[Grok / OpenCode Go]
OFFICIAL[央行 / BLS / BEA / Nasdaq iCal]
end
subgraph Data["本機資料"]
JSON[data/knowledge.json + notes.json]
SQLITE[(data.db)]
end
Browser -->|fetch /api| Server
Server --> Lib
Lib --> FREDAPI
Lib --> YAHOO
Lib --> SEC
Lib --> OFFICIAL
Server --> LLM
Lib --> JSON
Lib --> SQLITE
EMMY[(emmy/emmy)] -.->|build:knowledge| JSON
```
### 前端模組職責
| 檔案 | 職責 |
|------|------|
| `index.html` | 總經頁 DOM、卡片/Modal/圖表渲染、頂部 nav |
| `app.js` | `VIEW_IDS` 路由、`initCalendar/Learn/Stock/Journal/Settings`、wikilink/Markdown、AI context |
| `app.css` | 各視圖版面與元件樣式 |
### 後端模組職責
| 檔案 | 職責 |
|------|------|
| `server.js` | 路由表、TTL 常數、`buildPayload` / `refreshAndCache`、財報/股價快取邏輯、AI summarizer、`SETTINGS_FIELDS` |
---
## API 端點
所有 API 前綴為 `/api`,回傳 JSON錯誤時附 `error` 訊息)。
### 總經
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/api/macro` | 整包總經 payload`?fresh=1` 強制重抓 |
| GET | `/api/series/:key` | 單一指標歷史(走勢大圖) |
| GET | `/api/score-history` | 每日健康分數累積 |
| GET | `/api/events` | 歷史事件與危機案例 |
### 學習
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/api/knowledge` | 知識庫索引與 metadata |
| GET | `/api/note/:kind/:id` | 單篇筆記全文 |
| GET | `/api/graph` | vis-network 節點與邊 |
### 個股與日曆
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/api/fundamentals/:symbol` | 三表、比率、財測等 |
| GET | `/api/price/:symbol` | 歷史 OHLC`range`, `interval` |
| GET | `/api/quote/:symbol` | 即時報價 |
| GET | `/api/profile/:symbol` | 公司簡介 |
| GET | `/api/company-intel/:symbol` | 管理層、內部人、新聞 |
| GET | `/api/backtest/:symbol` | 策略回測結果 |
| GET | `/api/investmap` | 六層地圖設定 |
| GET | `/api/calendar` | 日曆事件 payload |
| GET/PUT | `/api/calendar/watchlist` | 追蹤財報代號清單 |
### 復盤
| 方法 | 路徑 | 說明 |
|------|------|------|
| GET | `/api/trades` | 交易列表 |
| GET | `/api/trades/stats` | 統計摘要 |
| POST | `/api/trades` | 新增 |
| PUT | `/api/trades/:id` | 更新 |
| DELETE | `/api/trades/:id` | 刪除 |
### AI 與設定
| 方法 | 路徑 | 說明 |
|------|------|------|
| POST | `/api/ai/models` | 依 provider 列出可用模型 |
| POST | `/api/ai/context` | 組裝目前頁面上下文 |
| POST | `/api/ai/chat` | 帶 context 的對話 |
| GET | `/api/settings/env` | 讀取設定(金鑰遮罩) |
| POST | `/api/settings/env` | 寫入 `.env` |
| GET | `/api/health` | `{ ok, knowledge }` 健康檢查 |
靜態檔:根目錄 `index.html`、`app.js`、`app.css` 由 Express 直接提供。
---
## 資料流與快取
| 功能 | 路徑 | 外部來源 | 持久化 |
|------|------|----------|--------|
| 總經 | `/api/macro` | FRED + Yahoo黃金 | 記憶體 + `cache`/`series`/`score_history` |
| 日曆 | `/api/calendar` | 多源 iCal / API | `cache`24h |
| 學習 | `/api/knowledge` | `data/*.json` | 建置時快照 |
| 財報 | `/api/fundamentals/:s` | Yahoo → SEC | 通用 cache + SEC 探針 |
| 股價 | `/api/price/:s` | Yahoo → Nasdaq | 日線 6h / 週月 1d TTL |
| 復盤 | `/api/trades` | — | `trades` 表 |
| AI | `/api/ai/chat` | Grok / OpenCode | 無持久(僅請求日誌於 console |
**SQLite 表(`lib/db.js`**
- `cache` — macro 整包、fundamentals、hist、quote、calendar 等 JSON
- `series` — 各 FRED 指標完整歷史點
- `score_history` — 每日健康分數
- `trades` — 使用者交易復盤
**財報快取邏輯(`server.js`**
1. 軟 TTL預設 12h完全不連網直接用 DB。
2. 超過軟 TTL輕量 SEC `getLatestFilingInfo` 探針;無新申報則續用快取。
3. 硬上限(預設 3 天)或探針失敗且過期:重抓 Yahoo/EDGAR。
失敗時盡量回傳 stale 資料並標記 `degraded`,避免畫面卡住。
---
## 建立知識庫
學習教材、財報健檢連結、投資地圖原則、知識圖譜皆依賴 `emmy/emmy` 快照:
```bash ```bash
npm run build:knowledge npm run build:knowledge
``` ```
產出: > `emmy/` 內容有更新時,重跑這個指令即可。若沒先執行,學習教材分頁會提示你建立。
- `data/knowledge.json` — 索引、分類、linkMap
- `data/notes.json` — 全文key = `kind:id`
`emmy/` 更新後重跑即可。未建置時學習頁會提示,且 `/api/health``knowledge: false`
--- ---
## 環境變數 ## 專案結構
`.env.example` ```
index.html 前端骨架 + 總經視圖(向 /api/macro 取資料後渲染)
app.js 學習/個股工具/交易復盤視圖 + 主視圖路由 + Markdown 渲染 + 共用折線圖
app.css 新視圖的樣式(沿用總經的深色主題變數)
server.js Express 伺服器:網頁 + 各 /api 路由(代理外部資料、快取)
lib/indicators.js 指標字典:序列代碼、中文名、分組、是否反向、解釋文字
lib/fred.js 抓取 FRED / Yahoo、做 YoY/MoM 換算、產生真實 sparkline
lib/score.js 用透明公式算出健康分數、景氣燈號與 5 個訊號
lib/db.js SQLite 存取:快取、序列、分數歷史、交易復盤 trades 表
lib/knowledge.js 載入 data/knowledge.json / notes.json學習教材
lib/fundamentals.js 抓財報SEC EDGAR 主、Yahoo quoteSummary 輔),正規化
lib/fincheck.js 財報基本功五步驟規則引擎,輸出紅綠燈 + 連回名詞/心法
lib/marketdata.js 抓歷史股價Yahoo v8 主、Nasdaq 免金鑰輔),給價格走勢與回測共用
lib/backtest.js 機械式策略回測純函式buyhold/dca/sma/dip + 績效統計
lib/investmap.js 投資地圖六層漏斗設定(每層提問清單 + 原則編號,由 server 補上標題/連結)
scripts/build-knowledge.mjs 把 ../emmy 快照成 data/*.json
data/ build:knowledge 產生的知識庫快照(已 gitignore 友善)
```
| 變數 | 預設 | 說明 | 資料流:
|------|------|------| - 總經:`瀏覽器 → /api/macro → (持金鑰) FRED → 換算/計分 → JSON → 渲染`
| `FRED_API_KEY` | — | 必填(總經) | - 學習:`build:knowledge 讀 emmy/ → data/*.json → /api/knowledge、/api/note → 渲染`
| `PORT` | `3000` | 伺服器埠 | - 財報:`/api/fundamentals/:symbol → EDGAR/Yahoo → fincheck 規則 → 紅綠燈 JSON`
| `CACHE_TTL_SECONDS` | `3600` | 總經整包快取 | - 價格:`/api/price/:symbol → Yahoo/NasdaqDB 快取)→ 收盤線圖`
| `FUND_SOFT_HOURS` | `12` | 財報軟 TTL | - 回測:`/api/backtest/:symbol → 用快取歷史跑 backtest → 權益曲線 + 統計`
| `FUND_HARD_DAYS` | `3` | 財報硬上限 | - 地圖:`/api/investmap → investmap 設定 + 知識庫原則 → 前端互動六層漏斗 → 可存成交易`
| `HIST_SOFT_HOURS` | `6` | 日線股價快取 | - 復盤:`/api/trades(.../stats) → SQLite trades 表 → 自動算損益與統計`
| `QUOTE_TTL_SECONDS` | `60` | 報價快取 |
| `GROK_*` / `OPENCODE_GO_*` | — | AI provider |
| `AI_ACTIVE_PROVIDER` | `grok` | 預設 provider |
---
## 技術棧
| 層級 | 技術 |
|------|------|
| 執行環境 | Node.js ≥ 18ESM |
| 後端 | Express 4、`dotenv`、內建 `node:sqlite` |
| 前端 | 純 HTML/CSS/Vanilla JS無 bundler |
| CDN | mermaid@11、vis-network@9 |
| 依賴數 | 僅 2 個 npm 套件express、dotenv |
外部 API 金鑰與抓取**全部經 server 代理**,不出現在瀏覽器。
--- ---
## 指標與資料來源 ## 指標與資料來源
絕大多數指標對應 FRED 序列;黃金改用 Yahoo 期貨FRED 無良好日線)。 絕大多數指標直接對應免費的 FRED 序列(利率、通膨、就業、成長、貨幣信用等)。
黃金因 FRED 無良好日線來源,改用 Yahoo Finance 期貨報價(伺服器端呼叫、免金鑰)。
### 免費替代指標(卡片標「替代」) ### 免費替代指標(畫面上會標示「替代」)
有少數指標屬於付費/專有資料,無法免費取得,因此用公認的免費等價指標替代,並在卡片上明確標示:
| 理想指標 | 替代序列 | 說明 | - **ISM 製造業 PMI** → 費城聯儲製造業景氣指數(`GACDFSA066MSFRBPHI`),大於 0 為擴張
|----------|----------|------| - **世界大型企業聯合會 消費者信心 CCI** → 密西根大學消費者信心指數(`UMCSENT`
| ISM 製造業 PMI | `GACDFSA066MSFRBPHI` | 費城聯儲製造業景氣 | - **領先指標 LEI** → 紐約聯儲殖利率曲線衰退機率模型(`RECPROUSM156N`
| 世界大型企業聯合會 CCI | `UMCSENT` | 密西根消費者信心 |
| 領先指標 LEI | `RECPROUSM156N` | NY Fed 衰退機率模型 |
另含工業生產年增(`INDPRO`)作實體經濟補充 此外加入「工業生產年增(`INDPRO`)」作為實體經濟的補充指標。
### 總經健康分數 ---
**50 分(中性)** 出發,依殖利率曲線、衰退機率、通膨、就業、信用利差等規則加減,限制 0100。中性參考指標美元、油價等不計分。 ## 總經健康分數怎麼算?
| 分數 | 燈號 | 從 50 分(中性)出發,依殖利率曲線、衰退機率、通膨、就業、信用利差、金融條件、製造業、
|------|------| 成長、波動率等規則加減分,最後限制在 0100。每一條規則都會列在分數的「?」說明裡,
| ≥ 65 | 景氣穩健 | 方向中性的指標(如美元、油價、股市本身)不計入分數,只作為參考。
| 5064 | 溫和成長 |
| 3549 | 景氣放緩 |
| < 35 | 衰退風險高 |
教學用簡化模型,**不構成投資建議**。 - 65 分以上:景氣穩健
- 5064溫和成長
- 3549景氣放緩
- 35 以下:衰退風險高
> 這是教學用的簡化模型,**不構成任何投資建議**。
--- ---
## 常見問題 ## 常見問題
- **總經顯示要設定金鑰?** → AI 設定頁填 `FRED_API_KEY` 或編輯 `.env` 後重整。 - **畫面顯示「設定 FRED 金鑰」?** 代表 `.env` 還沒設定或金鑰錯誤,照畫面步驟做即可。
- **學習頁空白?** → 確認 `finance/emmy/emmy` 存在,執行 `npm run build:knowledge` - **某些卡片顯示抓取失敗?** 個別序列偶爾延遲或維護,其餘仍是真實資料;按右上角「↻ 更新」可重抓。
- **個股或卡片抓取失敗?** → 按「↻ 更新」;多數有 stale 回退。 - **資料多久更新?** 後端快取 1 小時FRED 多數指標本身就是每日/每週/每月更新。
- **AI 沒帶頁面資料?** → 設定 provider + model在對應視圖個股學習筆記再開 AI 面板。
- **要改指標或分數規則?** → 編輯 `lib/indicators.js`、`lib/score.js`,重啟 server。
---
## 免責聲明
本專案所有內容、工具、AI 回覆、回測與統計**僅供個人學習與研究**,不構成投資、財務、稅務或任何專業建議。投資有風險,請以 FRED、SEC、Yahoo 等官方來源為準。過去績效不代表未來表現。

679
app.css
View File

@ -18,8 +18,6 @@
} }
.view[hidden]{display:none} .view[hidden]{display:none}
.view{min-width:0}
#main{min-width:0;overflow-x:clip}
body[data-view="macro"] #navLinks{display:flex} body[data-view="macro"] #navLinks{display:flex}
body:not([data-view="macro"]) #navLinks{display:none} body:not([data-view="macro"]) #navLinks{display:none}
@ -487,7 +485,7 @@ body:not([data-view="macro"]) #navLinks{display:none}
} }
.slb-head{display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:12px} .slb-head{display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:12px}
.slb-head b{font-size:.9rem}.slb-head span{font-size:.74rem;color:var(--text2)} .slb-head b{font-size:.9rem}.slb-head span{font-size:.74rem;color:var(--text2)}
.slb-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px} .slb-steps{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}
.slb-steps button{ .slb-steps button{
text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:11px 12px; text-align:left;border:1px solid var(--border);background:#f9faf7;border-radius:12px;padding:11px 12px;
color:var(--text);font:inherit;cursor:pointer;min-height:86px;transition:.15s; color:var(--text);font:inherit;cursor:pointer;min-height:86px;transition:.15s;
@ -501,7 +499,7 @@ body:not([data-view="macro"]) #navLinks{display:none}
.sub-tabs{ .sub-tabs{
display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:12px; display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:12px;
padding:4px;margin:8px 0 20px;flex-wrap:wrap;width:100%;max-width:100%; padding:4px;margin:8px 0 20px;flex-wrap:wrap;width:fit-content;
} }
.sub-tabs a{ .sub-tabs a{
padding:10px 20px;border-radius:10px;font-size:.86rem;font-weight:600; padding:10px 20px;border-radius:10px;font-size:.86rem;font-weight:600;
@ -510,8 +508,6 @@ body:not([data-view="macro"]) #navLinks{display:none}
.sub-tabs a:hover{color:var(--text)} .sub-tabs a:hover{color:var(--text)}
.sub-tabs a.active{background:var(--surface);color:var(--blue);box-shadow:0 1px 4px rgba(0,0,0,.08)} .sub-tabs a.active{background:var(--surface);color:var(--blue);box-shadow:0 1px 4px rgba(0,0,0,.08)}
.stk-pane[hidden]{display:none} .stk-pane[hidden]{display:none}
#view-stock,#stkBody,.stk-pane{min-width:0;width:100%;max-width:100%;overflow-x:clip;box-sizing:border-box}
#view-stock .page{max-width:100%;overflow-x:clip}
.metric-head{ .metric-head{
display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap; display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;
@ -546,8 +542,7 @@ body:not([data-view="macro"]) #navLinks{display:none}
background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px; background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;
box-shadow:var(--shadow); box-shadow:var(--shadow);
} }
.metric-section-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap;min-width:0} .metric-section-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.metric-section-head span{flex:1 1 100%;min-width:0;word-break:break-word;line-height:1.45}
.metric-section-head h3{font-size:.98rem;line-height:1.2} .metric-section-head h3{font-size:.98rem;line-height:1.2}
.metric-section-head span{font-size:.74rem;color:var(--text2)} .metric-section-head span{font-size:.74rem;color:var(--text2)}
.metric-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px} .metric-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
@ -641,87 +636,6 @@ body:not([data-view="macro"]) #navLinks{display:none}
cursor:pointer;font-size:1rem;line-height:1;padding:0;flex-shrink:0; cursor:pointer;font-size:1rem;line-height:1;padding:0;flex-shrink:0;
} }
.watch-chip-x:hover{background:var(--red);color:#fff} .watch-chip-x:hover{background:var(--red);color:#fff}
/* ── 追蹤個股(分群)── */
.watch-page .watch-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px}
.watch-status{font-size:.72rem;color:var(--text2);flex:1;min-width:120px}
.watch-msg{font-size:.76rem;padding:8px 12px;border-radius:10px;margin-bottom:10px;line-height:1.45}
.watch-msg.good{background:rgba(31,157,102,.08);color:var(--green);border:1px solid rgba(31,157,102,.2)}
.watch-msg.warn{background:rgba(200,138,29,.08);color:var(--orange);border:1px solid rgba(200,138,29,.2)}
.watch-msg.bad{background:rgba(216,79,69,.08);color:var(--red);border:1px solid rgba(216,79,69,.2)}
.watch-layout{
display:grid;grid-template-columns:minmax(200px,240px) minmax(0,1fr);gap:14px;align-items:start;
}
.watch-groups{
background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px;
box-shadow:var(--shadow);min-width:0;position:sticky;top:72px;
}
.watch-groups-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}
.watch-groups-head b{font-size:.88rem}
.watch-group-list{list-style:none;display:grid;gap:6px;margin:0;padding:0}
.watch-group-list li{display:flex;align-items:stretch;gap:4px;min-width:0}
.watch-group-item{
flex:1;min-width:0;display:flex;justify-content:space-between;align-items:center;gap:8px;
padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:#f9faf7;
cursor:pointer;font-family:inherit;text-align:left;color:var(--text);transition:.15s;
}
.watch-group-item:hover{border-color:rgba(35,103,199,.35)}
.watch-group-item.active{background:rgba(35,103,199,.08);border-color:rgba(35,103,199,.45);box-shadow:0 2px 8px rgba(35,103,199,.1)}
.watch-group-name{font-size:.82rem;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.watch-group-count{font-size:.7rem;color:var(--text2);background:#fff;border:1px solid var(--border);border-radius:999px;padding:2px 8px;flex-shrink:0}
.watch-group-del{
width:32px;border:1px solid var(--border);background:#fff;border-radius:10px;color:var(--text2);
cursor:pointer;font-size:1rem;line-height:1;flex-shrink:0;
}
.watch-group-del:hover{border-color:var(--red);color:var(--red)}
.watch-main{
background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;
box-shadow:var(--shadow);min-width:0;
}
.watch-main-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.watch-main-title{font-size:1.05rem;font-weight:820;margin:0;line-height:1.25}
.watch-main-sub{display:block;font-size:.72rem;color:var(--text2);margin-top:4px}
.watch-add-form{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
.watch-add-form input{
flex:1;min-width:140px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;
font-size:.86rem;font-family:inherit;
}
.watch-symbol-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:10px}
.watch-empty-panel{
grid-column:1/-1;padding:28px 16px;text-align:center;font-size:.82rem;color:var(--text2);
background:#f9faf7;border:1px dashed var(--border);border-radius:12px;line-height:1.55;
}
.watch-sym-card{
border:1px solid var(--border);border-radius:12px;background:#f9faf7;overflow:hidden;
display:flex;flex-direction:column;min-width:0;
}
.watch-sym-open{
flex:1;width:100%;padding:12px 14px;border:none;background:transparent;cursor:pointer;
text-align:left;font-family:inherit;color:var(--text);display:grid;gap:4px;min-width:0;
}
.watch-sym-open:hover{background:rgba(35,103,199,.05)}
.watch-sym-ticker{font-size:1rem;font-weight:900;color:var(--blue);letter-spacing:.02em}
.watch-sym-name{font-size:.68rem;color:var(--text2);line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-height:1em}
.watch-sym-price{font-size:1.12rem;font-weight:800;margin-top:4px}
.watch-sym-chg{font-size:.78rem;font-weight:700}
.watch-sym-actions{
display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;border-top:1px solid var(--border);
background:#fff;font-size:.7rem;
}
.watch-sym-move-wrap{display:flex;align-items:center;gap:4px;color:var(--text2)}
.watch-sym-move{
border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:.7rem;font-family:inherit;
max-width:120px;
}
.watch-sym-rm{
margin-left:auto;border:none;background:transparent;color:var(--text2);cursor:pointer;
font-size:.72rem;font-weight:700;font-family:inherit;padding:4px 6px;border-radius:6px;
}
.watch-sym-rm:hover{color:var(--red);background:rgba(216,79,69,.08)}
@media(max-width:820px){
.watch-layout{grid-template-columns:1fr}
.watch-groups{position:static}
}
.calendar-msg{font-size:.74rem;margin-top:8px;padding:8px 10px;border-radius:8px;line-height:1.45} .calendar-msg{font-size:.74rem;margin-top:8px;padding:8px 10px;border-radius:8px;line-height:1.45}
.calendar-msg.good{background:rgba(31,157,102,.08);color:#1a6b45;border:1px solid rgba(31,157,102,.18)} .calendar-msg.good{background:rgba(31,157,102,.08);color:#1a6b45;border:1px solid rgba(31,157,102,.18)}
.calendar-msg.warn{background:rgba(200,138,29,.08);color:#8a5a12;border:1px solid rgba(200,138,29,.18)} .calendar-msg.warn{background:rgba(200,138,29,.08);color:#8a5a12;border:1px solid rgba(200,138,29,.18)}
@ -822,13 +736,7 @@ body.cal-modal-open{overflow:hidden}
.event-impact.medium{background:var(--orange)} .event-impact.medium{background:var(--orange)}
.event-impact.low{background:var(--text2)} .event-impact.low{background:var(--text2)}
.event-symbol{font-size:.68rem;color:var(--blue);font-weight:850;background:rgba(35,103,199,.08);border-radius:999px;padding:3px 7px} .event-symbol{font-size:.68rem;color:var(--blue);font-weight:850;background:rgba(35,103,199,.08);border-radius:999px;padding:3px 7px}
#pane-price{min-width:0;overflow-x:clip} .stock-detail-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.8fr);gap:14px;align-items:start}
.stock-detail-layout{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,.85fr);gap:14px;align-items:start}
.stock-detail-layout > *{min-width:0}
#priceChart{width:100%;max-width:100%;min-width:0;overflow:hidden}
#priceChart .chart-root,#priceChart .chart-stage,#priceChart .chart-wrap,#priceChart .chart-area{width:100%;max-width:100%;min-width:0;box-sizing:border-box}
#priceChart .chart-wrap svg{width:100%;max-width:100%;height:auto;display:block}
#priceChart .chart-legend{max-width:100%}
.company-profile{ .company-profile{
background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow); background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow);
} }
@ -846,119 +754,25 @@ body.cal-modal-open{overflow:hidden}
.profile-events{display:grid;gap:6px;margin-top:12px;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px} .profile-events{display:grid;gap:6px;margin-top:12px;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px}
.profile-events b{font-size:.74rem} .profile-events b{font-size:.74rem}
.profile-events span{font-size:.72rem;color:var(--text2);line-height:1.4} .profile-events span{font-size:.72rem;color:var(--text2);line-height:1.4}
.company-intel{display:grid;gap:14px;margin-top:14px;width:100%;max-width:100%;min-width:0;box-sizing:border-box} .company-intel{display:grid;gap:14px;margin-top:14px}
.company-intel .intel-section{min-width:0}
.sec-archive-body{display:grid;gap:12px}
.sec-archive-block h4{margin:0 0 8px;font-size:.92rem;color:var(--text)}
.sec-archive-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}
.sec-filing-list,.sec-earn-list{display:grid;gap:8px}
.sec-filing-row,.sec-earn-row{display:grid;gap:8px;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
@media(min-width:720px){.sec-filing-row{grid-template-columns:1fr auto;align-items:start}}
.sec-filing-row--earn{border-color:rgba(59,130,246,.35)}
.sec-filing-main b{display:block;font-size:.95rem}
.sec-filing-form{font-size:.75rem;color:var(--muted);margin-left:6px}
.sec-filing-main small{display:block;color:var(--muted);margin-top:4px}
.sec-filing-main p,.sec-earn-row p{margin:6px 0 0;font-size:.82rem;color:var(--muted);line-height:1.45}
.sec-filing-excerpt{font-size:.78rem!important;opacity:.9}
.sec-filing-links{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.sec-filing-links a{font-size:.8rem}
.sec-missing{font-size:.75rem;color:var(--muted)}
.sec-earn-row small{display:block;color:var(--muted);margin-top:4px}
.intel-section{ .intel-section{
background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow); background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;box-shadow:var(--shadow);
} }
.intel-sync-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px;padding:10px 12px;background:#f4f6f2;border:1px solid var(--border);border-radius:12px} .chain-map{display:grid;grid-template-columns:1fr .8fr 1fr;gap:10px;align-items:stretch}
.intel-sync-bar span{font-size:.72rem;color:var(--text2);line-height:1.4;min-width:0;word-break:break-word} .chain-map div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;display:grid;gap:7px}
.intel-health-notes{margin:0 0 8px;padding:8px 12px 8px 28px;font-size:.72rem;color:var(--orange);background:rgba(200,138,29,.08);border:1px solid rgba(200,138,29,.2);border-radius:10px;line-height:1.5}
.intel-profile-text{margin:0;font-size:.82rem;line-height:1.6;color:var(--text);word-break:break-word}
.chain-map{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:start;width:100%;max-width:100%}
.chain-map--2{grid-template-columns:repeat(2,minmax(0,1fr))}
.chain-col--down{border-color:rgba(52,199,89,.22);background:#f6fbf7}
.chain-col--up{border-color:rgba(0,113,227,.18)}
.chain-excerpt{font-size:.78rem;color:var(--text2);line-height:1.55;margin:10px 0 0;word-break:break-word}
.intel-resource-links{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.intel-resource-links a{
font-size:.78rem;padding:6px 12px;border-radius:999px;border:1px solid var(--border);
background:var(--surface);color:var(--blue);text-decoration:none;max-width:100%;
}
.intel-resource-links a:hover{border-color:rgba(0,113,227,.35)}
.chain-map .chain-col{
background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;display:grid;gap:7px;
min-width:0;max-width:100%;overflow-x:hidden;overflow-y:auto;max-height:min(420px,70vh);align-content:start;
}
.chain-group--peers em{color:var(--blue)}
.chain-col--mid{border-color:rgba(35,103,199,.28);background:#f6f9ff}
.chain-map b{font-size:.82rem} .chain-map b{font-size:.82rem}
.chain-map span{font-size:.72rem;color:var(--text2);line-height:1.35;word-break:break-word} .chain-map span{font-size:.72rem;color:var(--text2);line-height:1.35}
.chain-mid-role{display:block;font-weight:700;color:var(--blue);margin-top:4px}
.chain-chips{display:flex;flex-wrap:wrap;gap:6px;width:100%;max-width:100%;min-width:0}
.chain-chips span,.chain-chip-static{
font-size:.7rem;padding:4px 8px;border-radius:999px;background:#fff;border:1px solid var(--border);
line-height:1.25;max-width:100%;word-break:break-word;overflow-wrap:anywhere;display:inline-block;
box-sizing:border-box;vertical-align:top;
}
.chain-chip-btn{
font-size:.7rem;padding:4px 10px;border-radius:999px;background:#fff;border:1px solid var(--border);
line-height:1.25;max-width:100%;word-break:break-word;overflow-wrap:anywhere;color:var(--blue);font-weight:800;
cursor:pointer;font-family:inherit;transition:border-color .15s,background .15s;
box-sizing:border-box;flex:0 1 auto;min-width:0;vertical-align:top;
}
.chain-chip-btn:hover{border-color:rgba(0,113,227,.4);background:rgba(0,113,227,.06)}
.peer-chips{width:100%;max-width:100%;min-width:0}
.peer-chips button{max-width:100%;overflow-wrap:anywhere;word-break:break-word;flex:0 1 auto;min-width:0}
.chain-group{display:grid;gap:5px;margin-bottom:8px}
.chain-group em{font-style:normal;font-size:.68rem;color:var(--muted);font-weight:700}
.chain-group small{font-size:.65rem;color:var(--text2)}
.intel-section--news{min-width:0;overflow:hidden}
.intel-section--news .metric-section-head{align-items:flex-start;flex-direction:column;gap:4px;margin-bottom:10px}
.intel-section--news .metric-section-head span{flex:none;width:100%}
.news-tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
.news-tab{
border:1px solid var(--border);background:#fff;border-radius:999px;padding:6px 14px;
font-size:.74rem;font-weight:800;cursor:pointer;color:var(--text2);font-family:inherit;
flex:0 1 auto;white-space:nowrap;
}
.news-tab.active{background:var(--blue);border-color:var(--blue);color:#fff}
.news-panel{min-width:0;width:100%}
.news-panel.hidden{display:none}
.news-list{display:flex;flex-direction:column;gap:10px;width:100%;min-width:0}
.news-empty{
padding:20px 14px;text-align:center;font-size:.8rem;color:var(--text2);
background:#f9faf7;border:1px dashed var(--border);border-radius:12px;
}
.mgmt-brief-list{display:grid;gap:8px}
.mgmt-brief-row{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:#f9faf7}
.mgmt-brief-row.good{border-left:4px solid var(--green)}
.mgmt-brief-row.bad{border-left:4px solid var(--red)}
.mgmt-brief-row.warn{border-left:4px solid var(--orange)}
.mgmt-brief-row b{display:block;font-size:.82rem;line-height:1.35;word-break:break-word}
.mgmt-brief-row small{display:block;color:var(--text2);margin-top:4px;font-size:.68rem}
.mgmt-brief-row p{margin:6px 0 0;font-size:.76rem;line-height:1.45;color:var(--text2);word-break:break-word}
.mgmt-brief-row a{font-size:.72rem}
.chain-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .chain-links{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.chain-links a,.peer-chips button,.chain-chip-btn{ .chain-links a,.peer-chips button{
border:1px solid var(--border);background:#fbfcfa;color:var(--blue);border-radius:999px; border:1px solid var(--border);background:#fbfcfa;color:var(--blue);border-radius:999px;
padding:6px 10px;font-size:.72rem;font-weight:800;cursor:pointer; padding:6px 10px;font-size:.72rem;font-weight:800;cursor:pointer;
} }
.peer-chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .peer-chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.officer-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr));gap:10px} .officer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
.officer-card{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px} .officer-card{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px}
.officer-card b{display:block;font-size:.82rem} .officer-card b{display:block;font-size:.82rem}
.officer-card span{display:block;font-size:.72rem;color:var(--text2);line-height:1.4;margin-top:5px} .officer-card span{display:block;font-size:.72rem;color:var(--text2);line-height:1.4;margin-top:5px}
.officer-card small{display:block;font-size:.68rem;color:var(--text2);margin-top:8px} .officer-card small{display:block;font-size:.68rem;color:var(--text2);margin-top:8px}
.profile-desc-note{margin:4px 0 0;font-size:.68rem;color:var(--text2);line-height:1.4}
.intel-notes{margin:0 0 10px;font-size:.8rem;line-height:1.55;color:var(--text)}
.intel-section--custom{background:#fafbf9}
.intel-custom-hint{margin:0 0 8px;font-size:.74rem;color:var(--text2);line-height:1.5}
.intel-custom-hint code{font-size:.7rem;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:4px}
.intel-custom-json{
width:100%;box-sizing:border-box;font-family:ui-monospace,monospace;font-size:.72rem;
line-height:1.45;padding:10px 12px;border:1px solid var(--border);border-radius:10px;
background:#fff;resize:vertical;min-height:140px;
}
.intel-custom-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}
.intel-custom-status{font-size:.72rem;color:var(--text2)}
.news-card-en{display:block;font-size:.68rem;color:var(--text2);margin-top:2px;line-height:1.35;word-break:break-word}
.insider-list{display:grid;gap:8px} .insider-list{display:grid;gap:8px}
.insider-summary{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:10px} .insider-summary{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:10px}
.insider-summary div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px} .insider-summary div{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px}
@ -975,29 +789,12 @@ body.cal-modal-open{overflow:hidden}
.insider-row.warn{border-left:5px solid var(--orange)} .insider-row.warn{border-left:5px solid var(--orange)}
.insider-row b{display:block;font-size:.8rem} .insider-row b{display:block;font-size:.8rem}
.insider-row span{display:block;font-size:.68rem;color:var(--text2);margin-top:3px;line-height:1.35} .insider-row span{display:block;font-size:.68rem;color:var(--text2);margin-top:3px;line-height:1.35}
.news-card{ .news-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
display:flex;flex-direction:column;gap:6px;min-width:0;width:100%;box-sizing:border-box; .news-card{background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px;color:var(--text)}
background:#f9faf7;border:1px solid var(--border);border-radius:12px;padding:12px 14px; .news-card:hover{border-color:rgba(35,103,199,.28)}
color:var(--text);text-decoration:none;transition:border-color .15s,box-shadow .15s; .news-card b{display:block;font-size:.82rem;line-height:1.35}
} .news-card span{display:block;font-size:.68rem;color:var(--text2);margin-top:7px}
.news-card:hover{border-color:rgba(35,103,199,.35);box-shadow:0 2px 10px rgba(35,103,199,.08)} .news-card p{font-size:.72rem;color:var(--text2);line-height:1.5;margin-top:8px}
.news-card-title{
font-size:.84rem;font-weight:800;line-height:1.4;color:var(--text);
word-break:break-word;overflow-wrap:anywhere;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
.news-card-meta{display:block;font-size:.68rem;color:var(--text2);line-height:1.35}
.news-card-summary{
margin:0;font-size:.72rem;color:var(--text2);line-height:1.5;
word-break:break-word;overflow-wrap:anywhere;
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;
}
@media(max-width:960px){
.stock-detail-layout{grid-template-columns:1fr}
}
@media(max-width:820px){
.chain-map,.chain-map--2{grid-template-columns:1fr}
}
@media(max-width:520px){ @media(max-width:520px){
.metric-grid{grid-template-columns:1fr 1fr} .metric-grid{grid-template-columns:1fr 1fr}
.metric-card{min-height:116px} .metric-card{min-height:116px}
@ -1013,27 +810,25 @@ body.cal-modal-open{overflow:hidden}
.cal-detail-row{grid-template-columns:1fr} .cal-detail-row{grid-template-columns:1fr}
.cal-detail-meta{text-align:left;margin-top:6px} .cal-detail-meta{text-align:left;margin-top:6px}
.stock-detail-layout{grid-template-columns:1fr} .stock-detail-layout{grid-template-columns:1fr}
.chain-map{grid-template-columns:1fr}
.insider-row{grid-template-columns:1fr} .insider-row{grid-template-columns:1fr}
.slb-steps{grid-template-columns:1fr 1fr} .slb-steps{grid-template-columns:1fr 1fr}
} }
.chart-root{display:flex;flex-direction:column;gap:8px;min-width:0;width:100%}
.chart-stage{position:relative;min-width:0;width:100%}
.chart-wrap{ .chart-wrap{
position:relative;width:100%;background:var(--surface); position:relative;width:100%;background:var(--surface);
border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow); border:1px solid var(--border);border-radius:var(--radius);padding:12px;box-shadow:var(--shadow);
} }
.chart-wrap svg{display:block;width:100%;height:auto} .chart-wrap svg{display:block;width:100%;height:auto}
.chart-empty{padding:48px 0;text-align:center;color:var(--text2)} .chart-empty{padding:48px 0;text-align:center;color:var(--text2)}
.chart-legend{display:flex;flex-wrap:wrap;gap:8px 16px;font-size:.78rem;color:var(--text2);margin-bottom:4px} .chart-legend{display:flex;gap:16px;font-size:.78rem;color:var(--text2);margin-bottom:8px}
.chart-legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;vertical-align:middle} .chart-legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px}
.chart-hover{font-size:.8rem;color:var(--text2);margin-top:8px;min-height:1.2em;line-height:1.45} .chart-hover{font-size:.8rem;color:var(--text2);margin-top:8px;min-height:1.2em}
.range-btns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;max-width:100%;min-width:0} .range-btns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
.range-btns button{ .range-btns button{
background:var(--surface);border:1px solid var(--border);color:var(--text2); background:var(--surface);border:1px solid var(--border);color:var(--text2);
border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600; border-radius:10px;padding:8px 16px;font-size:.82rem;font-weight:600;
cursor:pointer;font-family:inherit;box-shadow:var(--shadow);transition:.15s; cursor:pointer;font-family:inherit;box-shadow:var(--shadow);transition:.15s;
flex:0 1 auto;max-width:100%;min-width:0;
} }
.range-btns button:hover{border-color:var(--blue)} .range-btns button:hover{border-color:var(--blue)}
.range-btns button.active{background:var(--blue);border-color:var(--blue);color:#fff} .range-btns button.active{background:var(--blue);border-color:var(--blue);color:#fff}
@ -1255,57 +1050,20 @@ body.cal-modal-open{overflow:hidden}
@media(max-width:600px){ .form-grid{grid-template-columns:1fr} } @media(max-width:600px){ .form-grid{grid-template-columns:1fr} }
/* AI Provider / Page Assistant */ /* AI Provider / Page Assistant */
.ai-settings-grid{display:grid;grid-template-columns:1fr;gap:14px;min-width:0} .ai-settings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px}
@media(min-width:720px){.ai-settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.ai-provider-card{ .ai-provider-card{
background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px; background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px;
box-shadow:var(--shadow);min-width:0;max-width:100%; box-shadow:var(--shadow);
}
.ai-provider-head{
display:flex;flex-wrap:wrap;justify-content:space-between;gap:10px 14px;
align-items:flex-start;margin-bottom:14px;
}
.ai-provider-head>div:first-child{flex:1 1 12rem;min-width:0;max-width:100%}
.ai-provider-head b{display:block;font-size:1rem}
.ai-provider-head span{display:block;font-size:.76rem;color:var(--text2);line-height:1.55;margin-top:5px;overflow-wrap:anywhere}
.ai-default{
font-size:.78rem;color:var(--text2);display:flex;gap:6px;align-items:center;
flex:0 0 auto;padding:6px 10px;border-radius:10px;background:#f9faf7;border:1px solid var(--border);
}
.ai-default input{accent-color:var(--blue);flex-shrink:0}
.ai-model-row{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch;width:100%;min-width:0}
.ai-model-row input{min-width:0;flex:1 1 12rem;width:100%;max-width:100%}
.ai-model-row button{flex:0 0 auto;align-self:stretch}
.ai-provider-foot{
display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px;
margin-top:12px;font-size:.74rem;color:var(--text2);
}
.ai-provider-foot>span{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;line-height:1.5}
@media(min-width:520px){.ai-provider-foot>span{flex:1 1 auto}}
.ai-settings-msg{
font-size:.82rem;color:var(--text2);margin-top:12px;line-height:1.55;
max-width:100%;overflow-wrap:anywhere;word-break:break-word;
}
.ai-settings-msg .md,.ai-settings-msg pre{max-width:100%;overflow-x:auto}
.ai-settings-msg .md table{display:block;overflow-x:auto}
/* 設定頁 */
.settings-page{max-width:1040px;min-width:0}
.settings-page .page-sub{max-width:100%}
.settings-env-path{
margin-top:10px;padding:10px 12px;font-size:.78rem;line-height:1.5;color:var(--text2);
background:#f9faf7;border:1px solid var(--border);border-radius:10px;
overflow-wrap:anywhere;word-break:break-all;max-width:100%;
}
.settings-page .form-grid{grid-template-columns:1fr}
.settings-page .field input,.settings-page .field textarea{max-width:100%}
.settings-page .env-provider-card{margin-bottom:14px}
.settings-page .form-actions{flex-wrap:wrap}
@media(max-width:479px){
.settings-page .form-actions .btn{width:100%}
.ai-model-row .btn{width:100%}
.ai-provider-foot .btn{width:100%}
} }
.ai-provider-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:14px}
.ai-provider-head b{display:block;font-size:1rem}.ai-provider-head span{display:block;font-size:.76rem;color:var(--text2);line-height:1.55;margin-top:5px}
.ai-default{font-size:.78rem;color:var(--text2);display:flex;gap:6px;align-items:center;white-space:nowrap}
.ai-default input{accent-color:var(--blue)}
.ai-model-row{display:flex;gap:8px;align-items:center}
.ai-model-row input{min-width:0;flex:1}
.ai-model-row button{flex-shrink:0}
.ai-provider-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px;font-size:.74rem;color:var(--text2)}
.ai-settings-msg{font-size:.82rem;color:var(--text2);margin-top:12px;line-height:1.55}
.ai-dock{position:fixed;right:22px;bottom:22px;z-index:380} .ai-dock{position:fixed;right:22px;bottom:22px;z-index:380}
.ai-fab{ .ai-fab{
width:54px;height:54px;border-radius:50%;border:none;background:#202421;color:#fff; width:54px;height:54px;border-radius:50%;border:none;background:#202421;color:#fff;
@ -1320,21 +1078,11 @@ body.cal-modal-open{overflow:hidden}
.ai-panel-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;border-bottom:1px solid var(--border);padding:14px 14px 10px;background:rgba(249,250,247,.92)} .ai-panel-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;border-bottom:1px solid var(--border);padding:14px 14px 10px;background:rgba(249,250,247,.92)}
.ai-panel-head b{display:block;font-size:.95rem}.ai-panel-head span{display:block;font-size:.72rem;color:var(--text2);line-height:1.45;margin-top:3px} .ai-panel-head b{display:block;font-size:.95rem}.ai-panel-head span{display:block;font-size:.72rem;color:var(--text2);line-height:1.45;margin-top:3px}
.ai-panel-head button{border:1px solid var(--border);background:#f9faf7;color:var(--text2);border-radius:8px;width:30px;height:30px;cursor:pointer;font-size:1rem} .ai-panel-head button{border:1px solid var(--border);background:#f9faf7;color:var(--text2);border-radius:8px;width:30px;height:30px;cursor:pointer;font-size:1rem}
.ai-toolbar{ .ai-provider-row{display:flex;gap:8px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface)}
display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr) auto;gap:8px 10px; .ai-provider-row select{
align-items:end;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface); flex:1;border:1px solid var(--border);border-radius:10px;background:#f9faf7;color:var(--text);
padding:9px 11px;font:inherit;font-size:.82rem;
} }
.ai-field{display:flex;flex-direction:column;gap:4px;min-width:0}
.ai-field-label{font-size:.66rem;font-weight:600;letter-spacing:.02em;color:var(--text2);text-transform:uppercase}
.ai-field select,.ai-toolbar select{
width:100%;min-width:0;max-width:100%;border:1px solid var(--border);border-radius:10px;
background:#f9faf7;color:var(--text);padding:8px 10px;font:inherit;font-size:.8rem;
line-height:1.35;appearance:none;
background-image:linear-gradient(45deg,transparent 50%,var(--text2) 50%),linear-gradient(135deg,var(--text2) 50%,transparent 50%);
background-position:calc(100% - 14px) calc(50% - 2px),calc(100% - 9px) calc(50% - 2px);
background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:28px;
}
.ai-toolbar-settings{align-self:end;white-space:nowrap}
.ai-chat{ .ai-chat{
flex:1;overflow:auto;padding:14px;background: flex:1;overflow:auto;padding:14px;background:
linear-gradient(180deg,rgba(35,103,199,.04),rgba(32,40,33,.02)); linear-gradient(180deg,rgba(35,103,199,.04),rgba(32,40,33,.02));
@ -1346,12 +1094,8 @@ body.cal-modal-open{overflow:hidden}
.ai-bubble{ .ai-bubble{
border:1px solid var(--border);background:#fff;color:var(--text); border:1px solid var(--border);background:#fff;color:var(--text);
border-radius:16px;padding:10px 12px;font-size:.86rem;line-height:1.6;box-shadow:0 1px 2px rgba(32,40,33,.05); border-radius:16px;padding:10px 12px;font-size:.86rem;line-height:1.6;box-shadow:0 1px 2px rgba(32,40,33,.05);
overflow-wrap:anywhere;word-break:break-word;max-width:100%; overflow-wrap:anywhere;
} }
.ai-bubble pre,.ai-bubble code{max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
.ai-bubble .md{max-width:100%;overflow-x:auto}
.ai-bubble .md table{display:block;max-width:100%;overflow-x:auto}
.ai-bubble .mermaid-wrap{max-width:100%;overflow-x:auto}
.ai-msg-user .ai-bubble{background:#2367c7;color:#fff;border-color:#2367c7;border-bottom-right-radius:5px} .ai-msg-user .ai-bubble{background:#2367c7;color:#fff;border-color:#2367c7;border-bottom-right-radius:5px}
.ai-msg-bot .ai-bubble{border-bottom-left-radius:5px} .ai-msg-bot .ai-bubble{border-bottom-left-radius:5px}
.ai-msg-meta{font-size:.66rem;color:var(--text2);margin:4px 6px 0} .ai-msg-meta{font-size:.66rem;color:var(--text2);margin:4px 6px 0}
@ -1372,349 +1116,4 @@ body.cal-modal-open{overflow:hidden}
.ai-typing i:nth-child(2){animation-delay:.15s}.ai-typing i:nth-child(3){animation-delay:.3s} .ai-typing i:nth-child(2){animation-delay:.15s}.ai-typing i:nth-child(3){animation-delay:.3s}
@keyframes aiTyping{0%,80%,100%{transform:translateY(0);opacity:.35}40%{transform:translateY(-3px);opacity:.8}} @keyframes aiTyping{0%,80%,100%{transform:translateY(0);opacity:.35}40%{transform:translateY(-3px);opacity:.8}}
.ai-error{color:var(--red);background:rgba(216,79,69,.08);border:1px solid rgba(216,79,69,.16);border-radius:10px;padding:10px 12px} .ai-error{color:var(--red);background:rgba(216,79,69,.08);border:1px solid rgba(216,79,69,.16);border-radius:10px;padding:10px 12px}
@media(max-width:520px){ @media(max-width:520px){.ai-dock{right:16px;bottom:16px}.ai-panel{height:min(82vh,680px)}.ai-provider-row,.ai-model-row{align-items:stretch;flex-direction:column}}
.ai-dock{right:16px;bottom:16px}
.ai-panel{width:calc(100vw - 24px);height:min(82vh,680px)}
.ai-toolbar{grid-template-columns:1fr 1fr;grid-template-rows:auto auto}
.ai-toolbar-settings{grid-column:1/-1;justify-self:end}
.ai-settings-grid{grid-template-columns:1fr}
}
/* ── 技術圖表頁(個股 · 技術圖表分頁)── */
.ta-page{display:flex;flex-direction:column;gap:16px;min-width:0;max-width:100%}
.ta-hero{
display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:16px 24px;
padding:18px 20px;background:var(--surface);border:1px solid var(--border);border-radius:16px;
box-shadow:var(--soft-shadow);
}
.ta-hero-title{margin:0;font-size:1.15rem;font-weight:800;line-height:1.3}
.ta-hero-sym{font-size:.82rem;font-weight:700;color:var(--text2);margin-left:6px}
.ta-hero-price{margin:6px 0 0;font-size:1.45rem;font-weight:900;font-variant-numeric:tabular-nums;color:var(--blue)}
.ta-hero-price small{font-size:.78rem;font-weight:600;color:var(--text2);margin-left:8px}
.ta-hero-kpis{display:flex;flex-wrap:wrap;gap:10px}
.ta-hero-kpis div{
min-width:88px;padding:10px 14px;background:#f9faf7;border:1px solid var(--border);border-radius:12px;
}
.ta-hero-kpis span{display:block;font-size:.68rem;color:var(--text2);font-weight:700;margin-bottom:4px}
.ta-hero-kpis b{font-size:1rem;font-weight:800;font-variant-numeric:tabular-nums}
.ta-hero-kpis small{display:block;font-size:.68rem;color:var(--text2);font-weight:600;margin-top:3px}
.ta-controls{
display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1fr) minmax(0,1.2fr) auto;gap:12px 16px;align-items:end;
padding:14px 16px;background:var(--surface);border:1px solid var(--border);border-radius:14px;
}
.ta-controls--panels{
grid-template-columns:1fr;align-items:stretch;margin-top:-4px;
}
.ta-panels-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px}
.ta-panels-row .chip-row{flex:1;min-width:0}
.ta-preset-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.ta-preset-label{font-size:.68rem;font-weight:700;color:var(--text2);white-space:nowrap}
.ta-control-group{display:flex;flex-direction:column;gap:8px;min-width:0}
.ta-control-group--layers{grid-column:span 1}
.ta-label{font-size:.75rem;font-weight:800;color:var(--text);letter-spacing:.02em}
.ta-control-hint{margin:0;font-size:.72rem;color:var(--text2);line-height:1.45}
.ta-refresh{align-self:end;white-space:nowrap}
.ta-section-title{margin:0 0 10px;font-size:.82rem;font-weight:800;color:var(--text2);letter-spacing:.04em}
.ta-chart-card{
background:var(--surface);border:1px solid var(--border);border-radius:16px;
box-shadow:var(--soft-shadow);min-width:0;overflow:visible;
}
.ta-chart-top{padding:14px 16px 10px;border-bottom:1px solid var(--border);background:#fafbf9}
.ta-meta-chips{display:flex;flex-wrap:wrap;gap:6px}
.ta-chip{
font-size:.7rem;font-weight:700;color:var(--text2);background:#fff;border:1px solid var(--border);
border-radius:999px;padding:4px 10px;white-space:nowrap;
}
.ta-chip--ok{color:var(--green);border-color:rgba(52,168,83,.35);background:rgba(52,168,83,.08)}
.ta-db-range{margin:8px 0 0;font-size:.72rem;color:var(--text2);line-height:1.4;word-break:break-all}
.ta-legend{
display:flex;flex-wrap:wrap;gap:8px 14px;padding:10px 16px;border-bottom:1px solid var(--border);
background:#fff;min-height:40px;align-items:center;
}
.ta-leg-item{display:inline-flex;align-items:center;gap:6px;font-size:.76rem;color:var(--text2);font-weight:600;white-space:nowrap}
.ta-leg-item i{width:14px;height:3px;border-radius:2px;flex-shrink:0}
.ta-workflow-hint{
margin:0;padding:8px 16px;font-size:.72rem;color:var(--text2);line-height:1.5;
border-bottom:1px solid var(--border);background:#fafbf9;
}
/* TradingView 式圖表Y 軸固定欄 + 可捲動圖區 + 固定時間軸 */
.tv-chart{border-top:1px solid var(--border);background:#fff;overflow:hidden}
.tv-chart-body{display:flex;position:relative;min-width:0;background:#fafbfc;overflow:hidden}
.tv-y-col{
flex:0 0 64px;width:64px;min-width:64px;display:flex;flex-direction:column;
border-right:1px solid var(--border);background:#fff;z-index:8;
position:sticky;left:0;align-self:stretch;
}
.tv-y-slot{
position:relative;flex-shrink:0;border-bottom:1px solid var(--border);
overflow:visible;background:#fff;
}
.tv-y-slot:last-child{border-bottom:none}
.tv-y-slot-label{
position:absolute;top:0;left:0;right:0;z-index:2;
font-size:9px;font-weight:800;color:var(--text2);text-align:center;
padding:3px 2px;background:#f5f7f4;border-bottom:1px solid var(--border);
pointer-events:none;
}
.tv-y-slot--sub{padding-top:20px;box-sizing:border-box}
.chart-gutter-y__ticks,.tv-scale-tags{
position:absolute;inset:0;pointer-events:none;
}
.tv-y-slot--sub .chart-gutter-y__ticks,.tv-y-slot--sub .tv-scale-tags{top:20px}
.tv-scale-tags{z-index:4}
.tv-scale-tags--stack{
display:flex;align-items:flex-start;justify-content:flex-start;
padding:4px 3px 6px;box-sizing:border-box;overflow-y:auto;overflow-x:hidden;
max-height:100%;
}
.tv-scale-stack{
display:flex;flex-direction:column;gap:5px;width:100%;max-width:100%;
}
.tv-scale-tag--stacked{
position:relative;transform:none;top:auto;right:auto;left:auto;
display:flex;align-items:center;justify-content:space-between;gap:5px;
width:100%;box-sizing:border-box;padding:4px 6px;min-height:26px;
}
.tv-scale-tag{
position:absolute;right:3px;transform:translateY(-50%);
display:inline-flex;align-items:center;gap:4px;max-width:calc(100% - 6px);
padding:4px 6px;border-radius:5px;background:var(--tag-bg,#2367c7);color:#fff;
font-size:9px;font-weight:700;line-height:1.3;white-space:nowrap;
box-shadow:0 1px 6px rgba(0,0,0,.18);pointer-events:none;
}
.tv-scale-tags--vol-hover{
position:absolute;left:0;right:0;bottom:4px;top:auto;height:auto;
z-index:5;pointer-events:none;padding:0 3px;
}
.tv-scale-vol-pin{
display:flex;flex-direction:column;gap:3px;width:100%;
}
.tv-scale-vol-sub{
display:block;font-size:8px;font-weight:700;color:var(--text2);
text-align:center;line-height:1.2;padding:0 2px 2px;
}
.chart-gutter-y__ticks:empty{display:none}
.tv-scale-leader{
position:absolute;right:100%;top:50%;
width:1px;height:var(--drift,8px);
margin-right:2px;
background:rgba(0,0,0,.25);
transform:translateY(calc(-50% + var(--dir,1) * var(--drift,8px) / 2 * -1));
pointer-events:none;
}
.tv-scale-tag em{font-style:normal;opacity:.92;font-size:8px;flex-shrink:0}
.tv-scale-tag b{font-weight:800;font-variant-numeric:tabular-nums;flex-shrink:0}
.chart-gutter-y__tick{right:2px;z-index:1}
.tv-scroll{
flex:1;min-width:0;overflow-x:auto;overflow-y:hidden;
cursor:grab;-webkit-overflow-scrolling:touch;
}
.tv-scroll.is-dragging{cursor:grabbing;user-select:none}
.tv-stack{display:flex;flex-direction:column;min-width:max-content}
.tv-pane--main{background:linear-gradient(180deg,#fff 0%,#f8faf9 100%);border-bottom:1px solid var(--border)}
.tv-pane--solo{border-bottom:none}
.tv-sub-panel{position:relative;border-bottom:1px solid var(--border);background:#fff}
.tv-sub-plot{background:#fff}
.ta-sub-close--float{
position:absolute;top:4px;right:6px;z-index:6;
width:22px;height:22px;border-radius:6px;border:1px solid var(--border);
background:rgba(255,255,255,.92);font-size:1rem;line-height:1;
cursor:pointer;color:var(--text2);font-weight:700;
}
.ta-sub-close--float:hover{background:#fff;color:var(--text)}
.chart-gutter-y{position:relative;width:100%;height:100%}
.chart-gutter-y__tick{
position:absolute;right:4px;transform:translateY(-50%);
font-size:10px;font-weight:600;color:#5c6562;
font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;
}
.tv-plot,.chart-plot-area{flex-shrink:0;background:#fff}
.tv-plot .chart-wrap,.tv-plot svg,.chart-plot-area svg{display:block;width:100%;height:100%}
.chart-root--tv .chart-stage,.chart-root--tv .chart-wrap{margin:0;padding:0;border:none;box-shadow:none;background:transparent}
.tv-cursor-y{
position:absolute;left:2px;z-index:8;transform:translateY(-50%);
padding:2px 6px;border-radius:4px;background:var(--blue);color:#fff;
font-size:10px;font-weight:700;font-variant-numeric:tabular-nums;
pointer-events:none;white-space:nowrap;box-shadow:0 2px 8px rgba(35,103,199,.35);
}
.tv-x-wrap{
display:flex;position:relative;height:28px;
border-top:1px solid var(--border);background:#fff;
}
.tv-x-pad{flex:0 0 64px;width:64px;min-width:64px;border-right:1px solid var(--border);background:#fafbf9}
.tv-chart{display:block}
.tv-chart-view{display:flex;flex-direction:column;min-width:0}
.tv-chart-foot{
flex-shrink:0;width:100%;background:#fff;
border-top:1px solid var(--border);
position:sticky;bottom:0;z-index:12;
box-shadow:0 -4px 12px rgba(0,0,0,.06);
}
.ta-readout-wrap--pinned{
display:block!important;min-height:52px;width:100%;box-sizing:border-box;
}
.tv-y-slot .tv-scale-tags,
.tv-y-slot .tv-scale-tags--vol-hover,
.tv-y-slot .tv-scale-tag{display:none!important}
.tv-x-track{flex:1;position:relative;min-width:0;overflow:hidden}
.tv-x-track .ta-x-axis__tick{top:8px}
.ta-x-axis__tick{
position:absolute;transform:translateX(-50%);
font-size:10px;font-weight:600;color:#5c6562;white-space:nowrap;
pointer-events:none;font-variant-numeric:tabular-nums;
}
.tv-cursor-x{
position:absolute;top:3px;z-index:8;transform:translateX(-50%);
padding:2px 8px;border-radius:4px;background:#202421;color:#f5f7f4;
font-size:10px;font-weight:700;pointer-events:none;white-space:nowrap;
box-shadow:0 2px 8px rgba(0,0,0,.2);
}
.tv-readout{min-height:0}
.ta-readout-wrap{
padding:10px 14px 12px;
background:linear-gradient(180deg,#f6f8fc 0%,#eef2f8 100%);
border-bottom:1px solid var(--border);
}
.ta-readout__chips{
display:flex;flex-wrap:wrap;gap:8px;align-items:stretch;
}
.ta-readout-chip{
display:inline-flex;flex-direction:column;gap:3px;min-width:52px;max-width:100%;
padding:7px 11px;background:#fff;border:1px solid rgba(0,0,0,.08);
border-radius:10px;box-shadow:0 1px 2px rgba(0,0,0,.04);
flex:0 1 auto;
}
.ta-readout-chip em{
font-size:.64rem;font-weight:800;color:var(--text2);
letter-spacing:.04em;font-style:normal;line-height:1.2;
}
.ta-readout-chip b{
font-size:.84rem;font-weight:800;color:var(--text);
font-variant-numeric:tabular-nums;line-height:1.25;word-break:break-word;
}
.ta-readout-chip small{
font-size:.64rem;color:var(--text2);line-height:1.3;margin-top:-1px;
}
.ta-readout-chip--date{min-width:108px}
.ta-readout-chip--date b{font-size:.78rem;font-weight:700}
.ta-readout-chip--close b{color:var(--blue)}
.ta-readout-chip--vol-today{border-color:rgba(35,103,199,.25);background:#f5f9ff}
.ta-readout-chip--vol-up{border-color:rgba(200,138,29,.35);background:#fffbf0}
.ta-readout-chip--vol-up b{color:#9a6b12}
.ta-readout-chip--vol-spike{border-color:rgba(216,79,69,.35);background:#fff6f5}
.ta-readout-chip--vol-spike b{color:#b91c1c}
.ta-readout-chip__badge{
align-self:flex-start;margin-top:2px;padding:2px 7px;border-radius:5px;
font-size:.62rem;font-weight:800;line-height:1.2;letter-spacing:.02em;
}
.ta-readout-chip__badge--elevated{background:#fff6e0;color:#9a6b12}
.ta-readout-chip__badge--spike{background:#fde8e6;color:#b91c1c}
.ta-chip--vol-spike{background:#fde8e6;color:#b91c1c;border:1px solid #f0b4ae;font-weight:800}
.ta-chip--vol-elevated{background:#fff6e0;color:#9a6b12;border:1px solid #ecd9a8;font-weight:800}
.vol-bar--active{stroke:#202421;stroke-width:1.2}
.vol-bar--spike.vol-bar--active{stroke:#b91c1c}
.ta-glossary-bar{
display:grid;grid-template-columns:auto 1fr;gap:8px 12px;align-items:center;
padding:10px 14px 12px;border-top:1px solid var(--border);background:#fafbf9;
}
.ta-glossary-title{
font-size:.72rem;font-weight:800;color:var(--text2);white-space:nowrap;
padding-top:2px;
}
.ta-glossary-chips{
display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;min-width:0;
}
.ta-glossary-chips .info-btn{margin:0;flex-shrink:0}
.ta-stat-label{
display:inline-flex;align-items:center;gap:4px;
font-size:.7rem;color:var(--text2);font-weight:700;line-height:1.35;
}
.ta-stat-label .info-btn{margin:0;vertical-align:baseline}
.ta-hero-kpis .ta-stat-label{display:inline-flex}
.chart-row-sticky{display:flex;flex-direction:row;align-items:stretch;width:max-content}
.chart-plot-area{flex-shrink:0}
.ta-sub-panel{border-bottom:1px solid var(--border);background:#fff}
.ta-sub-panel[hidden]{display:none}
.ta-sub-panel-head{
display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:6px 12px;background:#f5f7f4;border-bottom:1px solid var(--border);
}
.ta-sub-panel-head span{font-size:.72rem;font-weight:800;color:var(--text2);letter-spacing:.02em}
.ta-sub-close{
border:none;background:transparent;color:var(--text2);font-size:1.1rem;line-height:1;
cursor:pointer;padding:2px 8px;border-radius:6px;font-weight:700;
}
.ta-sub-close:hover{background:rgba(0,0,0,.06);color:var(--text)}
.ta-subchart-body{min-height:88px;overflow:visible}
.ta-subchart-body .chart-row-sticky{border-bottom:none}
.ta-subchart-body svg{display:block;width:100%;height:auto}
.ta-panels-empty{
padding:20px 16px;text-align:center;font-size:.78rem;color:var(--text2);
border-bottom:1px solid var(--border);background:#fafbfc;
}
.ta-panels-empty[hidden]{display:none}
.ta-stats-wrap{min-width:0}
.ta-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
.ta-stat{
background:var(--surface);border:1px solid var(--border);border-radius:12px;
padding:12px 14px;min-width:0;box-shadow:var(--shadow);
}
.ta-stat span{display:block;font-size:.7rem;color:var(--text2);font-weight:700;line-height:1.35}
.ta-stat b{display:block;font-size:1.02rem;font-weight:800;margin-top:6px;font-variant-numeric:tabular-nums;word-break:break-word}
.ta-stat small{display:block;font-size:.68rem;color:var(--text2);margin-top:4px;line-height:1.35}
.ta-ai-card{
background:var(--surface);border:1px solid rgba(35,103,199,.2);border-radius:16px;
padding:18px 20px;min-width:0;box-shadow:var(--soft-shadow);
}
.ta-ai-desc{margin:0 0 12px;font-size:.8rem;color:var(--text2);line-height:1.55}
.ta-ai-actions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.ta-ai-out{
font-size:.86rem;line-height:1.65;color:var(--text);
background:#f9faf7;border:1px solid var(--border);border-radius:12px;
padding:14px 16px;min-height:80px;max-height:min(40vh,360px);
overflow-y:auto;overflow-x:hidden;min-width:0;
}
.ta-ai-out .ta-ai-md{min-width:0;overflow-wrap:anywhere;word-break:break-word}
.ta-ai-out .md{font-size:.86rem;line-height:1.65;max-width:100%}
.ta-ai-out .md>:first-child{margin-top:0}
.ta-ai-out .md>:last-child{margin-bottom:0}
.ta-ai-out .md h1{font-size:1.05rem;margin:.6em 0 .35em;padding:0;border:none}
.ta-ai-out .md h2{font-size:.98rem;margin:.75em 0 .35em;padding-bottom:.25em}
.ta-ai-out .md h3{font-size:.9rem;margin:.6em 0 .3em}
.ta-ai-out .md h4{font-size:.86rem;margin:.5em 0 .25em}
.ta-ai-out .md p{margin:.45em 0}
.ta-ai-out .md ul,.ta-ai-out .md ol{margin:.4em 0 .5em;padding-left:1.25em}
.ta-ai-out .md li{margin:.25em 0}
.ta-ai-out .md blockquote{
margin:.5em 0;padding:8px 12px;border-left:3px solid var(--blue);
background:rgba(35,103,199,.06);border-radius:0 8px 8px 0;
}
.ta-ai-out .md pre,.ta-ai-out .md table{display:block;max-width:100%;overflow-x:auto}
.ta-ai-out .md table{margin:.6em 0;font-size:.8rem}
.ta-ai-out .md hr{margin:.8em 0}
.ta-ai-out .ai-error,.ta-ai-out .ai-typing{word-break:break-word}
.chart-root--ta{width:100%;height:100%}
.chart-stage--ta{width:100%;height:100%}
@media(max-width:960px){
.ta-controls:not(.ta-controls--panels){grid-template-columns:1fr 1fr}
.ta-control-group--layers{grid-column:1/-1}
.ta-controls .ta-control-group:first-child{grid-column:1/-1}
.ta-refresh{grid-column:1/-1;justify-self:start}
.ta-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:560px){
.ta-controls{grid-template-columns:1fr}
.ta-hero-kpis{width:100%}
.ta-hero-kpis div{flex:1}
.ta-stat-grid{grid-template-columns:1fr}
.ta-glossary-bar{grid-template-columns:1fr;gap:6px}
.ta-readout-chip{min-width:46px;padding:6px 9px}
.ta-readout-chip--date{min-width:0;flex:1 1 100%}
.tv-y-col{flex:0 0 72px;width:72px;min-width:72px}
.tv-x-pad{flex:0 0 72px;width:72px;min-width:72px}
.tv-scale-tag{font-size:8px;padding:2px 4px}
.sub-tabs{width:100%;max-width:100%}
}

2483
app.js

File diff suppressed because it is too large Load Diff

View File

@ -180,8 +180,6 @@ a{color:var(--blue);text-decoration:none}
#tooltip .tip-row{margin-bottom:7px} #tooltip .tip-row{margin-bottom:7px}
#tooltip .tip-row:last-child{margin-bottom:0} #tooltip .tip-row:last-child{margin-bottom:0}
#tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px} #tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
#tooltip .tip-formula{margin:6px 0 0;padding:8px 10px;background:rgba(0,0,0,.35);border-radius:8px;font-size:.68rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
#tooltip .tip-caveat{color:#ffb4a8}
#tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px} #tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px}
#tooltip .tip-context{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1)} #tooltip .tip-context{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1)}
#tooltip .tip-link-hint{margin-top:8px;font-size:.68rem;color:#8fa0ff} #tooltip .tip-link-hint{margin-top:8px;font-size:.68rem;color:#8fa0ff}
@ -264,73 +262,6 @@ a{color:var(--blue);text-decoration:none}
.ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px} .ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px}
.ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem} .ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem}
/* ── 板塊熱力圖/輪動/資金 ── */
#group-sectors.section{margin-top:24px}
#group-sectors .sector-panel{margin:0;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:18px 20px;box-shadow:var(--soft-shadow);max-width:100%;overflow:hidden}
.sector-panel-head{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
.sector-panel-head h2{font-size:1.05rem;font-weight:800;margin:0}
.sector-panel-head p{margin:6px 0 0;font-size:.78rem;color:var(--text2);line-height:1.5;max-width:100%}
.sector-rotation-banner{background:#f6f8f4;border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:16px}
.sector-rotation-banner b{display:block;font-size:.92rem;margin-bottom:6px}
.sector-rotation-banner span{font-size:.78rem;color:var(--text2);line-height:1.55}
.sector-quad-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:12px}
.sector-quad{padding:10px;border-radius:10px;border:1px solid var(--border);background:#fff;min-width:0;overflow:hidden}
.sector-quad em{display:block;font-size:.68rem;color:var(--text2);font-style:normal;margin-bottom:6px}
.sector-quad span{font-size:.72rem;line-height:1.4;word-break:break-word;overflow-wrap:anywhere}
.sector-heatmap-wrap{margin-bottom:16px;max-width:100%}
.sector-heatmap{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
.sector-heat-cell{border-radius:10px;border:1px solid rgba(0,0,0,.06);padding:10px;min-height:88px;display:flex;flex-direction:column;justify-content:space-between}
.sector-heat-cell b{font-size:.8rem;line-height:1.25}
.sector-heat-cell small{font-size:.65rem;color:var(--text2)}
.sector-heat-val{font-size:1rem;font-weight:800;margin:6px 0}
.sector-heat-row{display:flex;gap:6px;flex-wrap:wrap;font-size:.62rem;color:var(--text2)}
.sector-heat-row span{background:rgba(255,255,255,.55);padding:2px 5px;border-radius:4px}
.sector-flow-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
.sector-flow-col h4{margin:0 0 10px;font-size:.82rem}
.sector-flow-bar{display:grid;gap:6px}
.sector-flow-item{display:grid;grid-template-columns:72px 1fr 52px;gap:8px;align-items:center;font-size:.72rem}
.sector-flow-item .bar{height:8px;border-radius:4px;background:rgba(0,0,0,.06);overflow:hidden}
.sector-flow-item .bar i{display:block;height:100%;border-radius:4px}
.sector-inst-wrap{overflow-x:auto;margin-bottom:8px;-webkit-overflow-scrolling:touch}
.sector-inst-table{width:100%;min-width:320px;border-collapse:collapse;font-size:.74rem;table-layout:fixed}
.sector-inst-table th,.sector-inst-table td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sector-inst-table th{color:var(--text2);font-weight:600}
.sector-inst-table td:first-child{white-space:normal}
.sector-inst-note{font-size:.7rem;color:var(--text2);line-height:1.5;margin-top:10px}
.sector-holdings-block{margin-top:18px;padding-top:16px;border-top:1px solid var(--border)}
.sector-holdings-block>h4{font-size:.82rem;margin:0 0 8px}
.sector-holdings-lead{font-size:.76rem;color:var(--text2);line-height:1.55;margin:0 0 12px}
.sector-holdings-top{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
.sector-hold-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:20px;border:1px solid var(--border);background:#f9faf7;font-size:.72rem}
.sector-hold-chip button{background:none;border:none;padding:0;color:var(--blue);font-weight:700;cursor:pointer;font-size:.72rem}
.sector-hold-chip small{color:var(--text2)}
.sector-holdings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.sector-hold-card{border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:#fafbf8;min-width:0}
.sector-hold-card h5{margin:0 0 4px;font-size:.8rem}
.sector-hold-card .hold-reason{font-size:.68rem;color:var(--text2);line-height:1.45;margin:0 0 10px}
.sector-hold-list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
.sector-hold-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;font-size:.72rem}
.sector-hold-list button{background:none;border:none;padding:0;text-align:left;color:var(--blue);font-weight:600;cursor:pointer;font-size:.72rem}
.sector-hold-list span{color:var(--text2);font-variant-numeric:tabular-nums}
.sector-hold-faq{margin-top:12px;font-size:.72rem;color:var(--text2);line-height:1.55}
.sector-hold-faq summary{cursor:pointer;color:var(--text);font-weight:600}
@media(max-width:1100px){
.macro-hero{grid-template-columns:1fr}
.signal-bar{grid-template-columns:1fr}
.signal-bar .signal-regime{flex-wrap:wrap}
}
@media(max-width:900px){
#group-sectors.section{margin-left:16px;margin-right:16px}
.sector-quad-grid{grid-template-columns:1fr 1fr}
.sector-flow-grid{grid-template-columns:1fr}
.sector-heatmap{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media(max-width:600px){
.sector-heatmap{grid-template-columns:repeat(2,minmax(0,1fr))}
.sector-quad-grid{grid-template-columns:1fr}
.sector-holdings-grid{grid-template-columns:1fr}
}
/* ── Responsive ── */ /* ── Responsive ── */
@media(max-width:900px){ @media(max-width:900px){
.macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px} .macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px}
@ -363,7 +294,6 @@ a{color:var(--blue);text-decoration:none}
<nav class="view-tabs" id="viewTabs"> <nav class="view-tabs" id="viewTabs">
<a data-view="macro" class="active">總經</a> <a data-view="macro" class="active">總經</a>
<a data-view="calendar">日曆</a> <a data-view="calendar">日曆</a>
<a data-view="watchlist">追蹤</a>
<a data-view="learn">學習教材</a> <a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a> <a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a> <a data-view="journal">交易復盤</a>
@ -384,7 +314,6 @@ a{color:var(--blue);text-decoration:none}
</div> </div>
</section> </section>
<section class="view" id="view-calendar" hidden></section> <section class="view" id="view-calendar" hidden></section>
<section class="view" id="view-watchlist" hidden></section>
<section class="view" id="view-learn" hidden></section> <section class="view" id="view-learn" hidden></section>
<section class="view" id="view-stock" hidden></section> <section class="view" id="view-stock" hidden></section>
<section class="view" id="view-journal" hidden></section> <section class="view" id="view-journal" hidden></section>
@ -581,7 +510,6 @@ function macroHeroHTML(data, scoreColor, signals){
<div class="hero-actions"> <div class="hero-actions">
<button class="action-link" data-scroll-target="group-rates">看利率壓力</button> <button class="action-link" data-scroll-target="group-rates">看利率壓力</button>
<button class="action-link" data-scroll-target="group-money">看信用壓力</button> <button class="action-link" data-scroll-target="group-money">看信用壓力</button>
<button class="action-link" data-scroll-target="group-sectors">看板塊輪動</button>
<button class="action-link" data-scroll-target="group-history">對照歷史</button> <button class="action-link" data-scroll-target="group-history">對照歷史</button>
</div> </div>
</section> </section>
@ -610,133 +538,7 @@ function macroHeroHTML(data, scoreColor, signals){
</div>`; </div>`;
} }
function fmtPct(v,d=1){ function render(data){
if(v==null||Number.isNaN(v)) return '—';
return (v>=0?'+':'')+v.toFixed(d)+'%';
}
function heatCellStyle(pct){
if(pct==null) return 'background:#f0f1ee';
const v=Math.max(-8,Math.min(8,pct));
if(v>=0) return `background:rgba(31,157,102,${0.15+v/8*0.45})`;
return `background:rgba(216,79,69,${0.15+Math.abs(v)/8*0.45})`;
}
function sectorSectionHTML(sd){
if(!sd||!sd.sectors) return '';
const rows=(sd.sectors||[]).filter(s=>!s.error);
const rot=sd.rotation||{};
const inst=sd.institutional||{};
const quadLabels={leading:'領漲',weakening:'轉弱',improving:'改善',lagging:'落後'};
const quadHtml=Object.keys(quadLabels).map(k=>{
const list=(rot.byQuadrant&&rot.byQuadrant[k])||[];
const names=list.map(e=>{
const m=rows.find(r=>r.etf===e);
return m?m.nameZh:e;
}).join('、')||'—';
return `<div class="sector-quad"><em>${quadLabels[k]}</em><span>${names}</span></div>`;
}).join('');
const heatHtml=rows.map(s=>{
const main=s.ret5d!=null?s.ret5d:s.ret1d;
return `<div class="sector-heat-cell" style="${heatCellStyle(main)}" title="${s.etf}">
<div><b>${s.nameZh}</b><small>${s.etf}</small></div>
<div class="sector-heat-val" style="color:${main>=0?'var(--green)':'var(--red)'}">${fmtPct(main)}</div>
<div class="sector-heat-row">
<span>1日 ${fmtPct(s.ret1d)}</span>
<span>20日 ${fmtPct(s.ret20d)}</span>
<span>RS ${fmtPct(s.rs20)}</span>
</div>
</div>`;
}).join('');
const maxFlow=Math.max(...rows.map(r=>Math.abs(r.flowScore||0)),0.01);
const flowBar=(list,title)=>{
const items=(list||[]).map(r=>{
const w=Math.min(100,Math.abs((r.flowScore||0)/maxFlow)*100);
const col=(r.flowScore||0)>=0?'var(--green)':'var(--red)';
return `<div class="sector-flow-item">
<span>${r.nameZh}</span>
<div class="bar"><i style="width:${w}%;background:${col}"></i></div>
<span>${fmtPct(r.ret5d)}</span>
</div>`;
}).join('');
return `<div class="sector-flow-col"><h4>${title}</h4><div class="sector-flow-bar">${items||'<span></span>'}</div></div>`;
};
const instRows=(inst.byAum||[]).slice(0,11).map(r=>`<tr>
<td>${r.nameZh} <small style="color:var(--text2)">${r.etf||''}</small></td>
<td>${inst.aumProxy?(r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'):('$'+(r.aumB!=null?r.aumB.toFixed(1):'—')+'B')}</td>
<td>${r.sharePct!=null?r.sharePct.toFixed(1)+'%':'—'}</td>
</tr>`).join('');
const instHead=inst.aumProxy?'<th>動能占比</th><th>相對權重</th>':'<th>ETF 規模</th><th>占 11 板塊合計</th>';
const exp=sd.stockExposure||{};
const topChips=(exp.topStocks||[]).slice(0,8).map(s=>`<span class="sector-hold-chip"><button type="button" class="stk-jump" data-sym="${s.symbol}">${s.symbol}</button><small>${s.name}</small></span>`).join('');
const packHtml=(exp.packs||[]).map(p=>`<div class="sector-hold-card">
<h5>${p.nameZh} <small style="color:var(--text2)">${p.etf}</small></h5>
<p class="hold-reason">${p.reason||''}</p>
<ul class="sector-hold-list">${(p.holdings||[]).map(h=>`<li><button type="button" class="stk-jump" data-sym="${h.symbol}">${h.symbol}</button><span>${h.pctFmt||(h.pct!=null?h.pct.toFixed(2)+'%':'—')}</span></li>`).join('')}</ul>
</div>`).join('');
const holdingsBlock=(exp.packs&&exp.packs.length)?`
<div class="sector-holdings-block">
<h4>機構資金落在哪些股票?</h4>
<p class="sector-holdings-lead">${exp.howToRead||''}</p>
${topChips?`<div class="sector-holdings-top">${topChips}</div>`:''}
<div class="sector-holdings-grid">${packHtml}</div>
<details class="sector-hold-faq"><summary>ETF 持股 vs 13F差在哪</summary>
<p style="margin-top:8px"><b>這裡ETF 持股)</b>:看 XLK、SPY 等基金「裡面裝什麼」,反映被動指數與板塊 ETF 的結構性配置,更新約每月。</p>
<p style="margin-top:6px"><b>13F</b>:美國管理超過 1 億美元機構每季申報的「股票部位」清單(含主動基金),約延遲 45 天,可在 SEC EDGAR 查 Berkshire、Bridgewater 等個別持倉。</p>
<p style="margin-top:6px"><b>近期流向</b>:上方「資金流向偏強/偏弱」是價量推估,不是逐筆買賣;要追單一股票可再到「個股工具」看量能與新聞。</p>
</details>
<p class="sector-inst-note">${exp.disclaimer||''}</p>
</div>`:'';
const cached=sd.cached?` · 快取${sd.cachedAt?new Date(sd.cachedAt).toLocaleString('zh-TW',{hour:'2-digit',minute:'2-digit'}):''}`:'';
return `
<div class="section" id="group-sectors">
<div class="sector-panel">
<div class="sector-panel-head">
<div>
<h2>板塊熱力圖與資金輪動</h2>
<p>以 SPDR 11 大行業 ETF 對照 ${sd.benchmark||'SPY'}:熱力圖看漲跌、輪動看相對強度、流向看價量、下方可看 ETF 實際持股(機構多透過 ETF 間接持有)。${cached}</p>
</div>
<button class="refresh-btn" type="button" id="sectorRefreshBtn">↻ 更新板塊</button>
</div>
<div class="sector-rotation-banner">
<b>目前輪動:${rot.regime||'—'}${rot.leader?` · 領先 <span style="color:var(--green)">${rot.leader.nameZh}</span>`:''}${rot.laggard?` · 落後 <span style="color:var(--red)">${rot.laggard.nameZh}</span>`:''}</b>
<span>${rot.regimeNote||''}</span>
<div class="sector-quad-grid">${quadHtml}</div>
</div>
<h4 style="font-size:.82rem;margin:0 0 8px">板塊熱力圖 <small style="color:var(--text2);font-weight:400">(格內主數字為 5 日漲跌,列為 1日20日相對大盤 RS</small></h4>
<div class="sector-heatmap-wrap"><div class="sector-heatmap">${heatHtml}</div></div>
<div class="sector-flow-grid">
${flowBar(inst.flowLeaders,'近期資金流向偏強')}
${flowBar(inst.flowLaggards,'近期資金流向偏弱')}
</div>
<h4 style="font-size:.82rem;margin:0 0 8px">板塊 ETF 規模${inst.aumProxy?'(流向動能占比)':'(總資產)'}</h4>
<div class="sector-inst-wrap">
<table class="sector-inst-table">
<thead><tr><th>板塊</th>${instHead}</tr></thead>
<tbody>${instRows}</tbody>
</table>
</div>
<p class="sector-inst-note">${inst.disclaimer||''}${inst.totalAumB!=null?` 合計約 $${inst.totalAumB.toFixed(0)}B。`:''}</p>
${holdingsBlock}
</div>
</div>`;
}
function sectorFailHTML(reason){
const msg=reason||'板塊資料暫時無法載入。請確認已用最新程式啟動伺服器(終端機執行 npm start再按 Cmd+Shift+R 強制重新整理。';
return `
<div class="section" id="group-sectors">
<div class="sector-panel">
<div class="sector-panel-head">
<div>
<h2>板塊熱力圖與資金輪動</h2>
<p>${msg}</p>
</div>
<button class="refresh-btn" type="button" id="sectorRefreshBtn">↻ 重試板塊</button>
</div>
</div>
</div>`;
}
function render(data, sectorData, sectorFailed){
const main=document.getElementById('view-macro'); const main=document.getElementById('view-macro');
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow'); const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
@ -749,8 +551,6 @@ function render(data, sectorData, sectorFailed){
TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]}; TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]};
let html = macroHeroHTML(data, scoreColor, signals) + guideHTML(); let html = macroHeroHTML(data, scoreColor, signals) + guideHTML();
if(sectorData) html += sectorSectionHTML(sectorData);
else if(sectorFailed) html += sectorFailHTML();
// 降級提示 // 降級提示
if(data.degraded&&data.degraded.length){ if(data.degraded&&data.degraded.length){
@ -759,7 +559,6 @@ function render(data, sectorData, sectorFailed){
// 各分組 // 各分組
const nav=[]; const nav=[];
if(sectorData||sectorFailed) nav.push(`<a data-target="group-sectors">板塊資金</a>`);
(data.groups||[]).forEach(g=>{ (data.groups||[]).forEach(g=>{
if(!g.cards||g.cards.length===0) return; if(!g.cards||g.cards.length===0) return;
nav.push(`<a data-target="group-${g.key}">${g.title}</a>`); nav.push(`<a data-target="group-${g.key}">${g.title}</a>`);
@ -792,10 +591,6 @@ function render(data, sectorData, sectorFailed){
main.innerHTML=html; main.innerHTML=html;
document.getElementById('navLinks').innerHTML=nav.join(''); document.getElementById('navLinks').innerHTML=nav.join('');
document.getElementById('sectorRefreshBtn')?.addEventListener('click',()=>loadSectors(true).then(sd=>{
if(!window.__MACRO_DATA) return;
render(window.__MACRO_DATA, sd||null, !sd);
}));
// 更新時間 // 更新時間
const t=new Date(data.updatedAt||Date.now()); const t=new Date(data.updatedAt||Date.now());
@ -810,12 +605,6 @@ function render(data, sectorData, sectorFailed){
const el=document.getElementById(btn.dataset.scrollTarget); const el=document.getElementById(btn.dataset.scrollTarget);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'}); if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
})); }));
document.querySelectorAll('.stk-jump').forEach(btn=>btn.addEventListener('click',()=>{
const sym=btn.dataset.sym;
if(!sym) return;
if(typeof window.setStockSymbol==='function') window.setStockSymbol(sym);
location.hash='#/stock';
}));
const sc=document.getElementById('scoreClick'); const sc=document.getElementById('scoreClick');
if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});} if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});}
} }
@ -1132,33 +921,18 @@ function bindChartHover(points,opts){
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 載入資料 // 載入資料
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
async function loadSectors(fresh){
try{
const r=await fetch('/api/sectors'+(fresh?'?fresh=1':''));
const sd=await r.json();
if(!r.ok) return null;
return sd;
}catch{ return null; }
}
async function load(fresh){ async function load(fresh){
const main=document.getElementById('view-macro'); const main=document.getElementById('view-macro');
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`; main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
try{ try{
const [res,evRes,sectorRes]=await Promise.all([ const [res,evRes]=await Promise.all([
fetch('/api/macro'+(fresh?'?fresh=1':'')), fetch('/api/macro'+(fresh?'?fresh=1':'')),
fetch('/api/events').catch(()=>null), fetch('/api/events').catch(()=>null),
fetch('/api/sectors'+(fresh?'?fresh=1':'')).catch(()=>null),
]); ]);
const data=await res.json(); const data=await res.json();
if(!res.ok){ renderError(data); return; } if(!res.ok){ renderError(data); return; }
if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} } if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} }
let sectorData=null; render(data);
let sectorFailed=false;
if(sectorRes&&sectorRes.ok){ try{sectorData=await sectorRes.json();}catch{sectorFailed=true;} }
else if(sectorRes) sectorFailed=true;
window.__MACRO_DATA=data;
render(data,sectorData,sectorFailed);
}catch(err){ }catch(err){
renderError({message:'無法連線到伺服器。請確認伺服器已啟動npm start。',detail:String(err)}); renderError({message:'無法連線到伺服器。請確認伺服器已啟動npm start。',detail:String(err)});
} }

View File

@ -1,86 +0,0 @@
// 伺服器端呼叫已設定的 AI ProviderOpenCode Go / Grok
const AI_PROVIDERS = {
'opencode-go': {
label: 'OpenCode Go',
endpoint: 'https://opencode.ai/zen/go/v1/chat/completions',
keyEnv: 'OPENCODE_GO_API_KEY',
modelEnv: 'OPENCODE_GO_MODEL',
mode: 'chat',
},
grok: {
label: 'Grok',
endpoint: 'https://api.x.ai/v1/responses',
keyEnv: 'GROK_API_KEY',
modelEnv: 'GROK_MODEL',
mode: 'responses',
},
};
function normalizeAIText(data, mode) {
if (mode === 'responses') {
if (data?.output_text) return data.output_text;
const chunks = [];
for (const item of data?.output || []) {
for (const c of item?.content || []) {
if (typeof c?.text === 'string') chunks.push(c.text);
else if (typeof c?.content === 'string') chunks.push(c.content);
}
}
return chunks.join('\n').trim();
}
return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
}
export function getActiveAIConfig() {
const providerId = String(process.env.AI_ACTIVE_PROVIDER || 'grok').trim();
const provider = AI_PROVIDERS[providerId];
if (!provider) return null;
const apiKey = String(process.env[provider.keyEnv] || '').trim();
const model = String(process.env[provider.modelEnv] || '').trim();
if (!apiKey) return null;
return { providerId, provider, apiKey, model };
}
export function extractJSONObject(text) {
const raw = String(text || '').trim();
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
const candidate = fenced || raw;
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
return null;
}
}
export async function callAI({ system, user, temperature = 0.15, timeoutMs = 90000 }) {
const cfg = getActiveAIConfig();
if (!cfg) return { ok: false, error: 'no_ai_key', text: null };
let { model, provider, apiKey, providerId } = cfg;
if (!model) model = 'grok-3-mini';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const body = provider.mode === 'responses'
? { model, store: false, temperature, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
: { model, temperature, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] };
const r = await fetch(provider.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: ctrl.signal,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
return { ok: false, error: data?.error?.message || data?.message || `HTTP ${r.status}`, text: null, providerId };
}
return { ok: true, text: normalizeAIText(data, provider.mode), providerId, model };
} catch (e) {
return { ok: false, error: String(e?.message || e), text: null, providerId };
} finally {
clearTimeout(timer);
}
}

View File

@ -1,312 +0,0 @@
// AI 整理公司研究為固定 JSON 結構(產業鏈、簡介、管理層動態、新聞摘要)
import { callAI, extractJSONObject } from './ai-client.js';
import { getCompanyIntelEnriched, saveCompanyIntelEnriched } from './db.js';
import { computeNextPublicRefresh, shouldRunIntelSync, intelRefreshPolicy } from './companyintel-refresh.js';
import { localizeOfficer, sanitizeOfficers } from './companyintel-i18n.js';
import {
mergeNewsIntoChain, finalizeIndustryChain, mergeEnrichedChain, layoutPeersIntoGrid, ensureDownstreamBuyers,
} from './companyintel-chain.js';
const ENRICH_SCHEMA = `{
"profileZh": {
"description": "80-220字繁體中文公司簡介",
"businessModel": "一句話商業模式"
},
"industryChain": {
"upstream": [{ "label": "環節名稱", "entities": ["供應商公司名或代號"], "note": "15字內標 10-K、新聞、AI" }],
"downstream": [{ "label": "客戶類型", "entities": ["購買標的公司產品/服務的公司名或代號"], "note": "15字內說明為何是客戶" }],
"peers": ["同業代號大寫台股如2330.TW"]
},
"managementBrief": [
{ "date": "YYYY-MM-DD", "headline": "標題", "summary": "2-3句繁中", "impact": "positive|neutral|negative", "source": "來源名" }
],
"newsHighlights": [
{ "region": "tw|global", "titleZh": "繁中標題", "summaryZh": "一句摘要", "url": "原文連結", "publisher": "媒體" }
]
}`;
function heuristicChain(symbol, bundle, profile = {}) {
const hints = bundle.hints || {};
const ext = bundle.profileExt || {};
const industry = `${ext.industry || ''} ${ext.sector || profile.industry || ''}`.toLowerCase();
const upstream = (hints.suppliers || []).slice(0, 12).map(s => ({ label: '供應商', entities: [s], note: '10-K' }));
const downstream = (hints.customers || []).slice(0, 12).map(s => ({ label: '購買方10-K', entities: [s], note: 'SEC 10-K' }));
const peers = [...new Set([...(ext.peers || []), ...(hints.competitors || [])])].filter(p => p !== symbol).slice(0, 10);
if (!upstream.length) {
if (/semiconductor|chip/i.test(industry)) {
upstream.push(
{ label: 'EDAIP', entities: ['Synopsys', 'Cadence'], note: '' },
{ label: '晶圓代工', entities: ['TSMC', 'Samsung'], note: '' },
{ label: '封裝測試', entities: ['ASE', 'Amkor'], note: '' },
);
}
}
if (!downstream.length && !/semiconductor|chip/i.test(industry)) {
/* 非半導體不填泛稱下游 */
}
let chain = {
upstream: upstream.length ? upstream : [{ label: '上游供應', entities: ['原物料/設備/服務商'], note: '待查證' }],
downstream: downstream.length ? downstream : [{ label: '下游客戶', entities: ['待查證'], note: '按強制更新由 AI 整理' }],
peers,
};
const news = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])];
chain = mergeNewsIntoChain(chain, news, symbol);
return ensureDownstreamBuyers(finalizeIndustryChain(chain, symbol), symbol, profile);
}
function fallbackManagementBrief(bundle) {
return (bundle.managementNewsRaw || []).slice(0, 6).map(n => ({
date: n.created || null,
headline: n.titleZh || n.title || '',
summary: (n.descriptionZh || n.description || '').slice(0, 180),
impact: 'neutral',
source: n.publisher || n.source || '',
url: n.url || null,
}));
}
function normalizeEnriched(parsed, symbol, bundle, profile) {
const chain = parsed?.industryChain || heuristicChain(symbol, bundle, profile);
const upDetail = (chain.upstream || []).some(g => g?.entities)
? chain.upstream
: (chain.upstreamDetail || []);
const downDetail = (chain.downstream || []).some(g => g?.entities)
? chain.downstream
: (chain.downstreamDetail || []);
const flatUpstream = upDetail.flatMap(u => (u.entities || []).map(e => String(typeof e === 'object' ? e.name : e)));
const flatDownstream = downDetail.flatMap(d => (d.entities || []).map(e => String(typeof e === 'object' ? e.name : e)));
const mgmt = (parsed?.managementBrief || []).length ? parsed.managementBrief : fallbackManagementBrief(bundle);
return {
profileZh: parsed?.profileZh || {
description: bundle.profileExt?.longBusinessSummary?.slice(0, 400) || bundle.hints?.excerpt?.slice(0, 400) || '',
businessModel: bundle.profileExt?.industry || '',
},
industryChain: finalizeIndustryChain({
upstream: flatUpstream.slice(0, 12),
downstream: flatDownstream.slice(0, 12),
peers: (chain.peers || []).map(s => String(s).toUpperCase()).filter(Boolean).slice(0, 12),
upstreamFlat: flatUpstream.slice(0, 12),
downstreamFlat: flatDownstream.slice(0, 12),
upstreamDetail: upDetail,
downstreamDetail: downDetail,
}, symbol),
managementBrief: mgmt.slice(0, 8).map(m => ({
date: m.date || null,
headline: m.headline || '',
summary: m.summary || '',
impact: m.impact || 'neutral',
source: m.source || '',
url: m.url || null,
})),
newsHighlights: (parsed?.newsHighlights || []).slice(0, 16),
enrichedAt: new Date().toISOString(),
aiUsed: !!parsed?._aiUsed,
provider: parsed?._provider || null,
};
}
function buildForceEnrichPrompt(symbol, profile = {}) {
const name = profile.name || profile.companyName || symbol;
const industry = profile.industry || profile.sector || '';
return [
`【強制更新】標的:${symbol}${name}${industry ? `,產業:${industry}` : ''}`,
'請依固定 JSON 結構輸出(不要 midstream頁面只顯示上游、下游兩欄。',
`upstream25 組供應商;每組 entities 為 26 個具體公司名或股票代號(美股 1-5 字大寫;台股 2330.TW`,
`downstream24 組「誰購買 ${symbol} 的產品或服務」;必須具名客戶(公司名或代號),禁止只寫終端客戶、企業客戶、通路等泛稱;優先採用 10-K customers 與新聞中的買方。`,
/NVDA|AMD/i.test(symbol)
? 'GPU 範例下游DELL、HPE、SMCIAI 伺服器 OEM採購 GPU 組裝再銷售、MSFT、AMZN、GOOGL、META雲端部署同業 AMD 放 peers 勿放 downstream。'
: null,
'peers38 個同業代號。',
'每個 downstream 的 note 用 15 字內說明客戶與標的公司的關係(如雲端採購 GPU、OEM 採購晶片)。',
'資料僅能來自提供的原始摘要;沒有依據則該組 entities 填「待查證」。',
].join('\n');
}
export async function enrichWithAI(symbol, bundle, profile = {}, { force = false } = {}) {
const compact = {
symbol,
sector: bundle.profileExt?.sector,
industry: bundle.profileExt?.industry,
summary: bundle.profileExt?.longBusinessSummary?.slice(0, 1200),
tenK: {
excerpt: bundle.hints?.excerpt?.slice(0, 1500),
customers: bundle.hints?.customers,
suppliers: bundle.hints?.suppliers,
competitors: bundle.hints?.competitors,
},
headlines8k: (bundle.headlines8k || []).slice(0, 6),
managementNews: (bundle.managementNewsRaw || []).slice(0, 8).map(n => ({
title: n.title, publisher: n.publisher, created: n.created, url: n.url,
})),
newsTw: (bundle.newsTw || []).slice(0, 10).map(n => ({
title: n.title, publisher: n.publisher, url: n.url,
summary: (n.description || n.descriptionZh || '').slice(0, 200),
})),
newsGlobal: (bundle.newsGlobal || []).slice(0, 10).map(n => ({
title: n.title, publisher: n.publisher, url: n.url,
summary: (n.description || n.descriptionZh || '').slice(0, 200),
})),
};
const system = force
? [
'你是股票研究資料編輯。只輸出一段合法 JSON不要 markdown不要解釋。',
'產業鏈僅 upstream供應商與 downstream購買標的公司產品服務的客戶不要 midstream。',
'downstream 是本次重點:具名買方公司或代號,結構穩定以利網頁兩欄顯示。',
'managementBrief 3-6 則newsHighlights 6-10 則region 為 tw 或 global。',
].join('\n')
: [
'你是股票研究資料編輯。只輸出一段合法 JSON不要 markdown不要解釋。',
'資料來自公開來源摘要,不可捏造未出現的公司名;不確定處用「待查證」。',
'upstream供應商downstream購買標的公司產品服務的客戶不可只寫泛稱。',
'不要輸出 midstream。entities 盡量用可交易代號note 標 10-K、新聞、AI。',
'managementBrief 只收經營層、治理、策略、併購、指引相關 3-6 則。',
'newsHighlights 從新聞挑選 6-10 則region 為 tw 或 global。',
].join('\n');
const task = force ? buildForceEnrichPrompt(symbol, { ...profile, ...bundle.profileExt }) : `股票代號 ${symbol}。請整理產業鏈與新聞。`;
const user = `${task}\n\nJSON 結構:\n${ENRICH_SCHEMA}\n\n原始資料:\n${JSON.stringify(compact, null, 2)}`;
const ai = await callAI({ system, user, temperature: 0.1 });
if (!ai.ok) {
return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: ai.error };
}
const parsed = extractJSONObject(ai.text);
if (!parsed) {
return { data: normalizeEnriched(null, symbol, bundle, profile), aiError: 'json_parse_failed' };
}
parsed._aiUsed = true;
parsed._provider = ai.providerId;
return { data: normalizeEnriched(parsed, symbol, bundle, profile), aiError: null };
}
export async function syncCompanyIntelEnriched(symbol, profile = {}, { force = false, useAI = true, management = null } = {}) {
symbol = String(symbol || '').trim().toUpperCase();
const existing = getCompanyIntelEnriched(symbol);
const gate = shouldRunIntelSync(existing, { force });
if (!gate.run) {
return {
symbol,
skipped: true,
enriched: existing?.data,
sources: existing?.sources || [],
skipReason: gate.skipReason,
nextRefreshAfter: gate.nextRefreshAfter,
};
}
const { gatherIntelSources } = await import('./companyintel-sources.js');
const bundle = await gatherIntelSources(symbol, profile);
const sources = ['Yahoo', 'SEC 10-K', 'Google 新聞 TW', 'Google 新聞 EN', 'Nasdaq', 'Yahoo Finance'];
let enriched;
let aiError = null;
if (useAI) {
const r = await enrichWithAI(symbol, bundle, profile, { force });
enriched = r.data;
aiError = r.aiError;
if (aiError) sources.push(`AI 略過(${aiError}`);
else sources.push(`AI ${enriched.provider || 'active'}`);
} else {
enriched = normalizeEnriched(null, symbol, bundle, profile);
}
const pub = await computeNextPublicRefresh(symbol);
enriched.rawBundleAt = bundle.gatheredAt;
enriched.sources = sources;
enriched.enrichedAt = new Date().toISOString();
enriched.lastSyncAt = Date.now();
enriched.chainLayout = 'upstream_downstream_v2';
if (force) enriched.forceRefreshAt = enriched.enrichedAt;
enriched.nextRefreshAfter = pub.nextRefreshAfter;
enriched.nextPublicLabel = pub.nextPublicLabel;
enriched.nextPublicDate = pub.nextPublicDate;
if (management?.officers?.length) {
enriched.officers = management.officers;
enriched.managementSource = management.source || null;
}
const newsAll = [...(bundle.newsTw || []), ...(bundle.newsGlobal || [])];
enriched.industryChain = ensureDownstreamBuyers(
layoutPeersIntoGrid(
mergeNewsIntoChain(
finalizeIndustryChain(enriched.industryChain || {}, symbol),
newsAll,
symbol,
),
symbol,
),
symbol,
profile,
);
saveCompanyIntelEnriched(symbol, enriched, sources);
return {
symbol, skipped: false, enriched, bundle, sources, aiError,
nextRefreshAfter: pub.nextRefreshAfter,
nextPublicLabel: pub.nextPublicLabel,
};
}
export function applyEnrichedToIntel(intel, enriched) {
if (!enriched) return intel;
const chain = enriched.industryChain || {};
const newsTw = (intel.newsTw || []).length ? intel.newsTw : (intel.news || []).filter(n => n.region === 'tw');
const newsGlobal = (intel.newsGlobal || []).length ? intel.newsGlobal : (intel.news || []).filter(n => n.region === 'global');
const highlights = enriched.newsHighlights || [];
const hlTw = highlights.filter(h => h.region === 'tw').map(h => ({
title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh,
url: h.url, publisher: h.publisher, region: 'tw', source: 'AI 精選',
}));
const hlGl = highlights.filter(h => h.region === 'global').map(h => ({
title: h.titleZh, titleZh: h.titleZh, descriptionZh: h.summaryZh, description: h.summaryZh,
url: h.url, publisher: h.publisher, region: 'global', source: 'AI 精選',
}));
return {
...intel,
profileZh: enriched.profileZh || intel.profileZh,
industryChain: ensureDownstreamBuyers(
mergeNewsIntoChain(
mergeEnrichedChain(intel.industryChain, enriched.industryChain, intel.symbol),
[...newsTw, ...newsGlobal],
intel.symbol,
),
intel.symbol,
intel.profile || {},
),
managementBrief: enriched.managementBrief || [],
management: (() => {
const off = sanitizeOfficers(enriched.officers);
if (!off.length) return intel.management;
return {
...(intel.management || {}),
officers: off.map(localizeOfficer),
source: enriched.managementSource || intel.management?.source,
};
})(),
newsTw: [...hlTw, ...newsTw].slice(0, 14),
newsGlobal: [...hlGl, ...newsGlobal].slice(0, 14),
news: [...newsTw, ...newsGlobal].slice(0, 20),
enrichedAt: enriched.enrichedAt || (enriched.lastSyncAt ? new Date(enriched.lastSyncAt).toISOString() : null),
enrichSources: enriched.sources || [],
aiEnriched: !!enriched.aiUsed,
chainLayout: enriched.chainLayout || 'upstream_downstream_v2',
nextRefreshAfter: enriched.nextRefreshAfter || null,
nextPublicLabel: enriched.nextPublicLabel || null,
needsSync: false,
};
}
export function attachIntelSyncStatus(intel, symbol) {
const row = getCompanyIntelEnriched(symbol);
const gate = intelRefreshPolicy(row);
return {
...intel,
needsSync: gate.needsSync,
nextRefreshAfter: gate.nextRefreshAfter || intel.nextRefreshAfter,
nextPublicLabel: gate.nextPublicLabel || intel.nextPublicLabel,
syncSkipReason: gate.skipReason,
lastSyncAt: gate.lastSyncAt || intel.enrichedAt,
};
}

View File

@ -1,463 +0,0 @@
// 產業鏈:新聞萃取、代號解析、實體可點擊結構
const UP_KW = /供應商|供應|上游|代工|材料|零件|設備|晶圓|封裝|HBM|EDA|IP|vendor|supplier|supply|manufactur|foundry|TSMC/i;
const DOWN_KW = /客戶|下游|訂單|採購|部署|採用|合作|需求|customer|deploy|adopt|partner|cloud|data\s*center|hyperscale|server|伺服器|OEM|ODM|rack/i;
const OEM_BUYER_CTX = /server|伺服器|OEM|ODM|rack|AI\s*server|GPU\s*server|AI\s*infrastructure|資料中心/i;
const GPU_NEWS_CTX = /NVIDIA|NVDA|輝達|英偉達|GPU|Blackwell|H100|B200|accelerator/i;
const DOWNSTREAM_BUYER_SYMS = new Set([
'DELL', 'HPE', 'HPQ', 'SMCI', 'CSCO', 'MSFT', 'AMZN', 'GOOGL', 'META', 'ORCL', 'AAPL', 'TSLA',
'2317.TW', '2382.TW', 'LENOVO',
]);
/** 公司名/中文簡稱 → 可切換代號 */
const NAME_ALIASES = {
台積電: 'TSM', 台积电: 'TSM', TSMC: 'TSM', 'Taiwan Semiconductor': 'TSM',
輝達: 'NVDA', 英偉達: 'NVDA', NVIDIA: 'NVDA',
超微: 'AMD', AMD: 'AMD',
高通: 'QCOM', Qualcomm: 'QCOM',
博通: 'AVGO', Broadcom: 'AVGO',
聯發科: '2454.TW', MediaTek: '2454.TW',
日月光: '3711.TW', ASE: '3711.TW',
鴻海: '2317.TW', Foxconn: '2317.TW', 富士康: '2317.TW',
廣達: '2382.TW', Quanta: '2382.TW',
聯電: 'UMC', 'United Microelectronics': 'UMC',
台塑: '1301.TW', 台塑化: '6505.TW', 中石化: '6505.TW',
中油: '6505.TW',
微軟: 'MSFT', Microsoft: 'MSFT',
谷歌: 'GOOGL', Google: 'GOOGL', Alphabet: 'GOOGL',
亞馬遜: 'AMZN', Amazon: 'AMZN',
蘋果: 'AAPL', Apple: 'AAPL',
Meta: 'META', 臉書: 'META', Facebook: 'META',
特斯拉: 'TSLA', Tesla: 'TSLA',
Synopsys: 'SNPS', Cadence: 'CDNS',
ASML: 'ASML', 'Applied Materials': 'AMAT', Lam: 'LRCX', KLA: 'KLAC',
美光: 'MU', Micron: 'MU',
三星: '005930.KS', Samsung: '005930.KS',
英特爾: 'INTC', Intel: 'INTC',
甲骨文: 'ORCL', Oracle: 'ORCL',
思科: 'CSCO', Cisco: 'CSCO',
戴爾: 'DELL', Dell: 'DELL',
惠普: 'HPE', HP: 'HPQ',
'Hewlett Packard Enterprise': 'HPE', 'Hewlett-Packard Enterprise': 'HPE',
超微電腦: 'SMCI', 'Super Micro': 'SMCI', Supermicro: 'SMCI',
'Dell Technologies': 'DELL',
亞馬遜雲: 'AMZN', AWS: 'AMZN',
微軟Azure: 'MSFT', Azure: 'MSFT',
};
function isUsTicker(s) {
return /^[A-Z]{1,5}$/.test(s);
}
function isTwTicker(s) {
return /^\d{4}(\.TW)?$/i.test(s);
}
const TICKER_BLOCKLIST = new Set([
'AI', 'IT', 'US', 'EU', 'UK', 'CEO', 'CFO', 'COO', 'GPU', 'CPU', 'CSP', 'API', 'EPS', 'SEC', 'IPO',
'ETF', 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'TWD', 'FY', 'QOQ', 'YOY', 'AND', 'THE', 'FOR', 'INC',
]);
export function isTradableSymbol(sym) {
const s = String(sym || '').toUpperCase().trim();
if (!s || TICKER_BLOCKLIST.has(s)) return false;
if (isUsTicker(s)) return true;
if (isTwTicker(s)) return true;
if (/^\d{6}\.KS$/i.test(s)) return true;
return false;
}
export function resolveEntitySymbol(raw, focalSymbol = '') {
const text = String(raw || '').trim();
if (!text || text === '待查證' || /^原物料|終端|通路|待查/i.test(text)) return null;
const focal = String(focalSymbol || '').toUpperCase();
const paren = text.match(/\(([A-Z]{1,5})\)/);
if (paren && paren[1] !== focal) return paren[1];
const dollar = text.match(/\$([A-Z]{1,5})\b/);
if (dollar && dollar[1] !== focal) return dollar[1];
if (isUsTicker(text) && text !== focal) return text;
if (isTwTicker(text)) {
const tw = text.replace(/\.tw$/i, '');
return `${tw}.TW`;
}
if (NAME_ALIASES[text]) return NAME_ALIASES[text];
const stripped = text.replace(/\s+(Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.|公司|股份|集團)/gi, '').trim();
if (NAME_ALIASES[stripped]) return NAME_ALIASES[stripped];
for (const [name, sym] of Object.entries(NAME_ALIASES)) {
if (name.length < 2) continue;
if (text.includes(name) && sym !== focal) return sym;
}
const inc = text.match(/^([A-Za-z][A-Za-z0-9&.\- ]{1,30})(?:\s+Inc\.|\s+Corp\.)/);
if (inc) {
const base = inc[1].trim();
if (NAME_ALIASES[base]) return NAME_ALIASES[base];
const words = base.split(/\s+/);
const last = words[words.length - 1];
if (last && NAME_ALIASES[last]) return NAME_ALIASES[last];
}
return null;
}
export function normalizeEntityItem(raw, focalSymbol = '') {
if (raw && typeof raw === 'object') {
const name = String(raw.name || raw.label || raw.symbol || '').trim();
const symbol = raw.symbol || resolveEntitySymbol(name, focalSymbol) || resolveEntitySymbol(raw.symbol, focalSymbol);
return { name: name || symbol || '—', symbol: symbol || null };
}
const name = String(raw || '').trim();
const symbol = resolveEntitySymbol(name, focalSymbol);
return { name, symbol };
}
function normalizeDetailGroups(groups, focalSymbol) {
return (groups || []).map(g => {
const entities = (g.entities || []).map(e => normalizeEntityItem(e, focalSymbol));
return { ...g, entities };
});
}
function groupFromItems(items, label, note) {
const ents = items.filter(i => i.name).slice(0, 10);
if (!ents.length) return null;
return { label, entities: ents, note };
}
/** 從近期新聞標題/摘要抽出上下游相關公司 */
export function extractChainFromNews(newsList = [], focalSymbol = '') {
const focal = String(focalSymbol || '').toUpperCase();
const upstream = [];
const downstream = [];
const related = [];
const seen = new Set([focal]);
const add = (bucket, item) => {
const sym = item.symbol || item.name;
const key = (sym || '').toUpperCase();
if (!key || seen.has(key)) return;
seen.add(key);
bucket.push(item);
};
for (const n of newsList) {
const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`;
if (!text.trim()) continue;
const up = UP_KW.test(text);
const down = DOWN_KW.test(text);
for (const m of text.matchAll(/\$([A-Z]{1,5})\b|\(([A-Z]{1,5})\)/g)) {
const sym = (m[1] || m[2] || '').toUpperCase();
if (!isUsTicker(sym) || sym === focal) continue;
const item = normalizeEntityItem(sym, focal);
if (up && !down) add(upstream, item);
else if (down && !up) add(downstream, item);
else add(related, item);
}
for (const [name, sym] of Object.entries(NAME_ALIASES)) {
if (!text.includes(name) || sym === focal) continue;
const item = normalizeEntityItem(name, focal);
item.symbol = sym;
if (up && !down) add(upstream, item);
else if (down && !up) add(downstream, item);
else add(related, item);
}
}
for (const n of newsList) {
const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`;
if (!/供應商|供货商|supplier|vendor|foundry|代工/i.test(text)) continue;
for (const [name, sym] of Object.entries(NAME_ALIASES)) {
if (!text.includes(name) || sym === focal) continue;
add(upstream, normalizeEntityItem(name, focal));
}
}
for (const n of newsList) {
const text = `${n.title || ''} ${n.titleZh || ''} ${n.description || ''} ${n.descriptionZh || ''}`;
const buyerCtx = OEM_BUYER_CTX.test(text) || (GPU_NEWS_CTX.test(text) && /Dell|HPE|Super|伺服器|server|OEM/i.test(text));
if (!buyerCtx) continue;
for (const [name, sym] of Object.entries(NAME_ALIASES)) {
if (!DOWNSTREAM_BUYER_SYMS.has(sym) || sym === focal || !text.includes(name)) continue;
const item = normalizeEntityItem(name, focal);
item.symbol = sym;
add(downstream, item);
}
}
return { upstream, downstream, related };
}
export const SECTOR_SUPPLIER_TICKERS = {
semiconductor: ['TSM', 'ASML', 'AMAT', 'LRCX', 'KLAC', 'MU', 'SNPS', 'CDNS'],
software: ['MSFT', 'AMZN', 'GOOGL'],
};
/** GPU加速器晶片常見下游OEM 伺服器廠 + 雲端買方 */
export const SECTOR_DOWNSTREAM_BUYERS = {
semiconductor: [
{ label: 'AI 伺服器 OEM', tickers: ['DELL', 'HPE', 'SMCI', 'CSCO'], note: '採購 GPU 組裝 AI 伺服器再銷售' },
{ label: '雲端與大型企業', tickers: ['MSFT', 'AMZN', 'GOOGL', 'META', 'ORCL'], note: '資料中心與 AI 工作負載' },
],
};
const SYMBOL_DOWNSTREAM_SECTOR = {
NVDA: 'semiconductor',
AMD: 'semiconductor',
INTC: 'semiconductor',
MRVL: 'semiconductor',
};
function isGpuSemiconductor(symbol, profile = {}) {
const sym = String(symbol || '').toUpperCase();
if (SYMBOL_DOWNSTREAM_SECTOR[sym]) return true;
const ind = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase();
return /semiconductor|chip/i.test(ind) && /graphic|gpu|accelerat|comput|processor|display/i.test(ind);
}
export function inferDownstreamGroups(symbol, profile = {}) {
const key = SYMBOL_DOWNSTREAM_SECTOR[String(symbol || '').toUpperCase()]
|| (isGpuSemiconductor(symbol, profile) ? 'semiconductor' : null);
if (!key || !SECTOR_DOWNSTREAM_BUYERS[key]) return [];
return SECTOR_DOWNSTREAM_BUYERS[key].map(b => ({
label: b.label,
entities: b.tickers.map(code => ({
name: code,
symbol: code,
confidence: 'medium',
source: 'sector_downstream',
})),
note: b.note,
confidence: 'medium',
}));
}
function downstreamHasTradableBuyers(detail, focalSymbol) {
const focal = String(focalSymbol || '').toUpperCase();
return (detail || []).some(g =>
(g.entities || []).some(e => {
const item = normalizeEntityItem(e, focal);
return item.symbol && isTradableSymbol(item.symbol) && item.symbol !== focal;
}),
);
}
function isGenericDownstreamGroup(g, focalSymbol) {
const ents = g?.entities || [];
if (!ents.length) return true;
return ents.every(e => {
const item = normalizeEntityItem(e, focalSymbol);
return !item.symbol || !isTradableSymbol(item.symbol);
});
}
export function ensureDownstreamBuyers(chain, symbol, profile = {}) {
let next = chain || {};
if (!downstreamHasTradableBuyers(next.downstreamDetail, symbol)) {
const inferred = inferDownstreamGroups(symbol, profile);
if (inferred.length) {
next = {
...next,
downstreamDetail: dedupeGroups([...inferred, ...(next.downstreamDetail || [])]),
chainSources: [...new Set([...(next.chainSources || []), '產業常見購買方'])],
};
}
}
if (downstreamHasTradableBuyers(next.downstreamDetail, symbol)) {
next = {
...next,
downstreamDetail: (next.downstreamDetail || []).filter(g => !isGenericDownstreamGroup(g, symbol)),
};
}
return finalizeIndustryChain(next, symbol);
}
/** 一律追加供應商/客戶名單(去重,不覆蓋既有分組) */
export function appendDetailNames(detail, names, label, note, focalSymbol = '') {
const incoming = (names || []).map(n => normalizeEntityItem(n, focalSymbol));
return mergeDetailGroups(detail, incoming, label, note);
}
function mergeDetailGroups(existing, incoming, label, note) {
const out = [...(existing || [])];
if (!incoming.length) return out;
const flat = out.flatMap(g => g.entities || []);
const have = new Set(flat.map(e => (e.symbol || e.name || '').toUpperCase()));
const fresh = incoming.filter(e => {
const k = (e.symbol || e.name || '').toUpperCase();
return k && !have.has(k);
});
if (!fresh.length) return out;
out.unshift({ label, entities: fresh, note });
return out.slice(0, 8);
}
/** 合併新聞萃取進產業鏈 */
export function mergeNewsIntoChain(chain, newsList, focalSymbol) {
const base = chain || {};
const { upstream, downstream, related } = extractChainFromNews(newsList, focalSymbol);
let upstreamDetail = mergeDetailGroups(base.upstreamDetail, upstream, '供應商/合作(新聞)', '近期公開新聞');
let downstreamDetail = mergeDetailGroups(base.downstreamDetail, downstream, '購買方(新聞)', '近期公開新聞');
let peers = [...(base.peers || [])];
for (const r of related) {
const sym = r.symbol;
if (sym && !peers.includes(sym) && sym !== focalSymbol) peers.push(sym);
}
peers = peers.filter(p => String(p).toUpperCase() !== focalSymbol).slice(0, 14);
return finalizeIndustryChain({
...base,
upstreamDetail,
downstreamDetail,
peers,
chainSources: [...new Set([...(base.chainSources || []), upstream.length || downstream.length ? '近期新聞' : null].filter(Boolean))],
}, focalSymbol);
}
/** 統一實體格式、補代號、重算 flat 列表 */
export function finalizeIndustryChain(chain, focalSymbol = '') {
const focal = String(focalSymbol || '').toUpperCase();
let upstreamDetail = normalizeDetailGroups(chain.upstreamDetail, focal)
.filter(g => (g.entities || []).length > 0);
let downstreamDetail = normalizeDetailGroups(chain.downstreamDetail, focal)
.filter(g => (g.entities || []).length > 0);
if (!upstreamDetail.length && Array.isArray(chain.upstream)) {
upstreamDetail = [{ label: '上游', entities: chain.upstream.map(e => normalizeEntityItem(e, focal)), note: '' }];
}
if (!downstreamDetail.length && Array.isArray(chain.downstream)) {
downstreamDetail = [{ label: '下游', entities: chain.downstream.map(e => normalizeEntityItem(e, focal)), note: '' }];
}
let peers = (chain.peers || []).map(p => {
const item = normalizeEntityItem(p, focal);
const sym = item.symbol || (isTradableSymbol(String(p)) ? String(p).toUpperCase() : null);
return isTradableSymbol(sym) ? sym : null;
}).filter(Boolean);
peers = [...new Set(peers)].filter(p => p !== focal).slice(0, 14);
const flatUp = upstreamDetail.flatMap(g => (g.entities || []).map(e => e.name)).filter(Boolean);
const flatDown = downstreamDetail.flatMap(g => (g.entities || []).map(e => e.name)).filter(Boolean);
return {
...chain,
upstream: flatUp.length ? flatUp : chain.upstream,
downstream: flatDown.length ? flatDown : chain.downstream,
upstreamDetail,
downstreamDetail,
peers,
searches: [],
};
}
const SUPPLIER_GROUP_RE = /供應|10-K|新聞|產業常見|合作/i;
const CUSTOMER_GROUP_RE = /客戶|購買|買方|OEM|ODM|伺服器|雲端|hyperscale|需求|10-K|新聞|產業/i;
function groupKey(g) {
return String(g?.label || '').trim();
}
function hasTradableEntity(g, focalSymbol = '') {
return (g?.entities || []).some(e => {
const item = normalizeEntityItem(e, focalSymbol);
return item.symbol && isTradableSymbol(item.symbol);
});
}
function isNamedSupplierGroup(g, focalSymbol = '') {
return SUPPLIER_GROUP_RE.test(g?.label || '') || hasTradableEntity(g, focalSymbol);
}
function isNamedCustomerGroup(g, focalSymbol = '') {
return CUSTOMER_GROUP_RE.test(g?.label || '') || hasTradableEntity(g, focalSymbol);
}
function dedupeGroups(groups) {
const out = [];
const seen = new Set();
for (const g of groups || []) {
const k = groupKey(g);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(g);
}
return out;
}
/** 合併 AI 產業鏈時保留已抓到的供應商/客戶分組,避免被泛稱覆蓋 */
export function mergeEnrichedChain(base = {}, enriched = {}, focalSymbol = '') {
const bUp = base.upstreamDetail || [];
const bDown = base.downstreamDetail || [];
const eUp = enriched.upstreamDetail || enriched.upstream || [];
const eDown = enriched.downstreamDetail || enriched.downstream || [];
const keepSuppliers = bUp.filter(g => isNamedSupplierGroup(g, focalSymbol));
const keepCustomers = bDown.filter(g => isNamedCustomerGroup(g, focalSymbol));
const eUpList = Array.isArray(eUp) ? eUp : [];
const eDownList = Array.isArray(eDown) ? eDown : [];
let upstreamDetail = dedupeGroups([
...keepSuppliers,
...eUpList.filter(g => !keepSuppliers.some(k => groupKey(k) === groupKey(g))),
]);
let downstreamDetail = dedupeGroups([
...keepCustomers,
...eDownList.filter(g => !keepCustomers.some(k => groupKey(k) === groupKey(g))),
]);
if (!upstreamDetail.length && eUpList.length) upstreamDetail = eUpList;
if (!downstreamDetail.length && eDownList.length) downstreamDetail = eDownList;
let chain = {
...base,
...enriched,
upstreamDetail,
downstreamDetail,
peers: [...new Set([...(base.peers || []), ...(enriched.peers || [])])],
tenKExcerpt: sanitizeChainExcerpt(enriched.tenKExcerpt || base.tenKExcerpt),
chainSources: [...new Set([...(base.chainSources || []), ...(enriched.chainSources || [])])],
};
chain = layoutPeersIntoGrid(chain, focalSymbol);
return finalizeIndustryChain(chain, focalSymbol);
}
/** 同業代號放進上游欄「同業/競爭」分組,不再堆在格子下方 */
export function layoutPeersIntoGrid(chain, focalSymbol = '') {
const focal = String(focalSymbol || '').toUpperCase();
const peers = (chain.peers || [])
.map(p => String(p).toUpperCase())
.filter(p => isTradableSymbol(p) && p !== focal);
if (!peers.length) return { ...chain, peers: [] };
const inGrid = new Set();
for (const g of [...(chain.upstreamDetail || []), ...(chain.downstreamDetail || [])]) {
for (const e of g.entities || []) {
const k = (e.symbol || e.name || '').toUpperCase();
if (k) inGrid.add(k);
}
}
const peerEntities = peers
.filter(sym => !inGrid.has(sym))
.map(sym => normalizeEntityItem(sym, focal));
if (!peerEntities.length) return { ...chain, peers: [] };
const upstreamDetail = [...(chain.upstreamDetail || [])];
const peerLabel = '同業/競爭';
const exist = upstreamDetail.find(g => groupKey(g) === peerLabel);
if (exist) {
const have = new Set((exist.entities || []).map(e => (e.symbol || e.name || '').toUpperCase()));
for (const e of peerEntities) {
const k = (e.symbol || e.name || '').toUpperCase();
if (k && !have.has(k)) { exist.entities.push(e); have.add(k); }
}
} else {
upstreamDetail.push({ label: peerLabel, entities: peerEntities, note: '同業標的' });
}
return { ...chain, upstreamDetail, peers: [] };
}
export function sanitizeChainExcerpt(text) {
const t = String(text || '').trim();
if (!t || t.length < 50) return null;
if (/^nvda-\d|000\d{7,}|\bFY\s+false\b/i.test(t.slice(0, 120))) return null;
return t.slice(0, 480);
}

View File

@ -1,154 +0,0 @@
// 公司研究資料:欄位中文化(職稱/產業常用對照,非機器翻譯全文)
const TITLE_ZH = [
[/chief executive officer|ceo/i, '執行長'],
[/chief financial officer|cfo/i, '財務長'],
[/chief operating officer|coo/i, '營運長'],
[/chief technology officer|cto/i, '技術長'],
[/executive vice president|evp/i, '執行副總'],
[/senior vice president|svp/i, '資深副總'],
[/vice president|vp/i, '副總'],
[/president.*chief executive|president and chief executive/i, '執行長暨總裁'],
[/president/i, '總裁'],
[/general counsel/i, '法務長'],
[/chief accounting officer/i, '會計長'],
[/principal financial officer/i, '主要財務負責人'],
[/principal executive officer/i, '主要執行負責人'],
[/principal accounting officer/i, '主要會計負責人'],
[/director/i, '董事'],
[/chairman/i, '董事長'],
[/operations/i, '營運'],
[/worldwide field/i, '全球業務'],
];
const SECTOR_ZH = {
Technology: '科技',
'Financial Services': '金融服務',
Healthcare: '醫療保健',
'Consumer Cyclical': '循環性消費',
'Consumer Defensive': '防禦性消費',
Energy: '能源',
Industrials: '工業',
'Basic Materials': '原物料',
'Real Estate': '房地產',
Utilities: '公用事業',
'Communication Services': '通訊服務',
};
const INDUSTRY_HINTS = [
[/semiconductor/i, '半導體'],
[/software/i, '軟體'],
[/internet/i, '網際網路'],
[/bank/i, '銀行'],
[/biotech|pharma/i, '生技/製藥'],
[/retail/i, '零售'],
[/auto/i, '汽車'],
];
export function looksLikePersonName(name) {
if (!name || name.length > 55) return false;
if (/^Item \d/i.test(name) || name === 'Action' || name.startsWith('/s/')) return false;
const n = name.toLowerCase();
if (/financial|exhibit|schedule|statement|supplementary|governance|table of|designated|hedge|accounting|income|operations|revenue|consolidated|index|former|current|named|other|each|page|directors and|from our|served as/.test(n)) return false;
const parts = name.trim().split(/\s+/);
if (parts.length < 2 || parts.length > 5) return false;
if (!/^[A-Z]/.test(parts[0])) return false;
return parts.every(p => /^[A-Za-z'.-]+$/.test(p));
}
export function looksLikeExecutiveTitle(title) {
if (!title || title.length > 100) return false;
if (/financial statement|exhibit|supplementary|schedule|table of contents|designated|hedge|accounting/i.test(title)) return false;
if (/income from|cost of revenue|gross profit|net income|operating income/i.test(title)) return false;
const t = title.toLowerCase();
if (t === 'director' || t === 'directors') return false;
return /chief|president|executive vice|general counsel|operations|officer|accounting|counsel|field/i.test(t);
}
export function isOfficerRow(name, title) {
return looksLikePersonName(name) && looksLikeExecutiveTitle(title);
}
export function sanitizeOfficers(list) {
return (list || []).filter(o => isOfficerRow(o.name, o.title));
}
export function translateOfficerTitle(title) {
const t = String(title || '').trim();
if (!t) return '';
for (const [re, zh] of TITLE_ZH) {
if (re.test(t)) return zh;
}
return t;
}
export function translateSector(sector) {
const s = String(sector || '').trim();
if (!s) return '—';
return SECTOR_ZH[s] || s;
}
export function translateIndustry(industry) {
const s = String(industry || '').trim();
if (!s) return '—';
for (const [re, zh] of INDUSTRY_HINTS) {
if (re.test(s)) return `${zh}${s}`;
}
return s;
}
export function localizeOfficer(o) {
const title = o.titleZh || o.title || '';
const titleZh = o.titleZh || translateOfficerTitle(title);
return {
...o,
title,
titleZh,
titleDisplay: titleZh && titleZh !== title ? `${titleZh} · ${title}` : (titleZh || title),
};
}
export function mergeCustomIntel(intel, custom) {
if (!custom) return intel;
const out = { ...intel, customUpdatedAt: custom.updatedAt || null };
if (custom.profileZh) {
out.profileZh = custom.profileZh;
}
if (custom.officers?.length) {
out.management = {
...out.management,
officers: custom.officers.map(localizeOfficer),
source: '本機自訂',
};
}
if (custom.news?.length) {
out.news = custom.news.map(n => ({
...n,
titleZh: n.titleZh || n.title,
descriptionZh: n.descriptionZh || n.description,
}));
}
if (custom.managementNotes) {
out.management = { ...out.management, notesZh: custom.managementNotes };
}
return out;
}
export function localizeIntel(intel) {
if (!intel) return intel;
const officers = sanitizeOfficers(intel.management?.officers || []).map(localizeOfficer);
const news = (intel.news || []).map(n => ({
...n,
titleZh: n.titleZh || n.title,
descriptionZh: n.descriptionZh || n.description,
}));
return {
...intel,
management: { ...intel.management, officers },
news,
searchesZh: (intel.management?.searches || []).map(s => ({
...s,
labelZh: s.labelZh || s.label.replace('Management', '管理層').replace('Leadership', '領導團隊'),
})),
};
}

View File

@ -1,148 +0,0 @@
// 公司研究直接連結SEC官網 IR與 10-K 產業鏈合併(不用 Google 搜尋代替資料)
import {
finalizeIndustryChain, isTradableSymbol, appendDetailNames, SECTOR_SUPPLIER_TICKERS,
} from './companyintel-chain.js';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
let _tickerMap = null;
async function json(url, headers = {}, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': SEC_UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(timer); }
}
async function tickerToCik(symbol) {
if (!_tickerMap) {
const d = await json('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA });
_tickerMap = {};
for (const k of Object.keys(d)) {
_tickerMap[String(d[k].ticker).toUpperCase()] = {
cik: String(d[k].cik_str).padStart(10, '0'),
cikNum: Number(d[k].cik_str),
name: d[k].title,
};
}
}
return _tickerMap[String(symbol || '').toUpperCase()] || null;
}
function edgarDocUrl(cikNum, accession, primary) {
const accNo = accession.replace(/-/g, '');
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNo}/${primary}`;
}
/** 投資人關係/官網(不經 Google */
export function resolveInvestorRelationsUrl(website) {
const w = String(website || '').trim();
if (!w) return null;
try {
const u = new URL(w.startsWith('http') ? w : `https://${w}`);
const host = u.hostname.replace(/^www\./, '');
const candidates = [
u.href,
`${u.protocol}//${u.host}/investor-relations`,
`${u.protocol}//investor.${host}`,
`${u.protocol}//ir.${host}`,
];
return { url: candidates[0], labelZh: '公司官網', altUrls: candidates.slice(1) };
} catch {
return null;
}
}
/** SEC 直接連結EDGAR、最新 10-K、DEF 14A */
export async function buildSecResourceLinks(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const links = [
{ labelZh: 'SEC EDGAR 公司頁', url: `https://www.sec.gov/edgar/browse/?CIK=${hit.cik}`, source: 'SEC' },
];
try {
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
const found = { '10-K': null, 'DEF 14A': null, '10-Q': null };
for (let i = 0; i < (f.form || []).length; i++) {
const form = f.form[i];
if (!found[form] && found[form] !== undefined) {
const acc = f.accessionNumber[i];
const doc = f.primaryDocument?.[i];
if (acc && doc) {
found[form] = edgarDocUrl(hit.cikNum, acc, doc);
}
}
if (found['10-K'] && found['DEF 14A'] && found['10-Q']) break;
}
if (found['10-K']) links.push({ labelZh: '最新 10-K 年報', url: found['10-K'], source: 'SEC' });
if (found['10-Q']) links.push({ labelZh: '最新 10-Q 季報', url: found['10-Q'], source: 'SEC' });
if (found['DEF 14A']) links.push({ labelZh: '股東會說明書 DEF 14A', url: found['DEF 14A'], source: 'SEC' });
} catch { /* */ }
return links;
}
function entityGroups(names, label, note) {
const list = (names || []).filter(Boolean).slice(0, 10);
if (!list.length) return [];
return [{ label, entities: list, note }];
}
/** 把 10-K 抽出的公司名+產業 fallback 合成上下游結構 */
export function mergeIndustryChainWithHints(symbol, chain, hints = {}, profileExt = {}, profile = {}) {
const base = chain || {};
const industry = `${profileExt.industry || ''} ${profileExt.sector || profile.industry || ''}`.toLowerCase();
let upstreamDetail = base.upstreamDetail?.length ? [...base.upstreamDetail] : [];
let downstreamDetail = base.downstreamDetail?.length ? [...base.downstreamDetail] : [];
let peers = base.peers?.length ? [...base.peers] : [...(profileExt.peers || [])];
if (hints.suppliers?.length) {
upstreamDetail = appendDetailNames(upstreamDetail, hints.suppliers, '供應商10-K', 'SEC 年報提及', symbol);
}
if (hints.customers?.length) {
downstreamDetail = appendDetailNames(downstreamDetail, hints.customers, '客戶10-K', 'SEC 年報提及', symbol);
}
const ind = industry.toLowerCase();
if (/semiconductor|chip/i.test(ind)) {
upstreamDetail = appendDetailNames(
upstreamDetail,
SECTOR_SUPPLIER_TICKERS.semiconductor,
'產業常見供應商',
'半導體鏈',
symbol,
);
}
if (hints.competitors?.length) {
const from10k = hints.competitors.map(c => String(c).replace(/\s+(Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.)/i, '').trim().toUpperCase())
.filter(c => isTradableSymbol(c));
peers = [...new Set([...peers, ...from10k])].filter(p => p !== symbol).slice(0, 12);
}
const flatUp = upstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean);
const flatDown = downstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean);
return finalizeIndustryChain({
...base,
upstream: flatUp.length ? flatUp : base.upstream,
downstream: flatDown.length ? flatDown : base.downstream,
upstreamDetail,
downstreamDetail,
peers,
tenKExcerpt: hints.excerpt ? String(hints.excerpt).slice(0, 480) : base.tenKExcerpt || null,
chainSource: hints.source || base.chainSource || (hints.excerpt ? 'SEC 10-K' : null),
chainSources: [...new Set([...(base.chainSources || []), hints.excerpt ? 'SEC 10-K' : null].filter(Boolean))],
searches: [],
}, symbol);
}
export async function buildCompanyResources(symbol, profile = {}, management = {}) {
const links = [];
const ir = resolveInvestorRelationsUrl(profile.website || management.website);
if (ir) links.push({ labelZh: '投資人關係/官網', url: ir.url, source: '官網' });
const sec = await buildSecResourceLinks(symbol).catch(() => []);
return [...links, ...sec];
}

View File

@ -1,68 +0,0 @@
// 公司研究同步節奏:首次進入必抓;之後等到「下次財報/公開」再更新
import { fetchEarningsEvents } from './calendar.js';
function addDaysISO(base, days) {
const d = new Date(base + 'T12:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
/** 下次允許重新抓取的日期 = 下一個財報日(尚無則約一季後) */
export async function computeNextPublicRefresh(symbol) {
symbol = String(symbol || '').trim().toUpperCase();
const today = todayISO();
const end = addDaysISO(today, 200);
try {
const events = await fetchEarningsEvents(today, end, [symbol]);
const upcoming = (events || [])
.filter(e => e.date && e.date > today)
.sort((a, b) => a.date.localeCompare(b.date));
if (upcoming.length) {
const next = upcoming[0];
return {
nextRefreshAfter: next.date,
nextPublicLabel: next.title || `${symbol} 財報`,
nextPublicDate: next.date,
};
}
} catch { /* fallback */ }
const fallback = addDaysISO(today, 92);
return {
nextRefreshAfter: fallback,
nextPublicLabel: '約一季後(暫無財報日曆)',
nextPublicDate: fallback,
};
}
export function intelRefreshPolicy(enrichedRow) {
const data = enrichedRow?.data || {};
const lastSyncAt = data.lastSyncAt || enrichedRow?.updatedAt || null;
const nextRefreshAfter = data.nextRefreshAfter || null;
const today = todayISO();
const neverSynced = !lastSyncAt;
const due = nextRefreshAfter ? today >= nextRefreshAfter : false;
const needsSync = neverSynced || due;
let skipReason = null;
if (!needsSync && nextRefreshAfter) {
skipReason = `已同步;下次更新:${nextRefreshAfter}${data.nextPublicLabel || '下次財報/公開'}`;
} else if (!needsSync) {
skipReason = '已同步';
}
return {
needsSync,
lastSyncAt: lastSyncAt ? new Date(lastSyncAt).toISOString() : null,
nextRefreshAfter,
nextPublicLabel: data.nextPublicLabel || null,
skipReason,
};
}
export function shouldRunIntelSync(enrichedRow, { force = false } = {}) {
if (force) return { run: true, reason: 'force' };
const p = intelRefreshPolicy(enrichedRow);
return { run: p.needsSync, reason: p.needsSync ? 'first_or_due' : 'wait_public', ...p };
}

View File

@ -1,338 +0,0 @@
// 公司研究多來源抓取台灣國際新聞、簡介、10-K 供應鏈線索、管理層動態)
import { yahooQuoteSummary, yahooFinanceSearchNews } from './yahoo-session.js';
import {
cleanNewsPlain, cleanGoogleNewsTitle, parseGoogleRssDescription, normalizeNewsItem,
} from './news-text.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
async function text(url, headers = {}, ms = 14000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally { clearTimeout(timer); }
}
async function json(url, headers = {}, ms = 14000) {
return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms));
}
const strip = (s) => cleanNewsPlain(s);
const tag = (block, name) => block.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || '';
function parseGoogleRss(xml, region, limit = 12) {
const items = [...String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)]
.map(m => m[1])
.slice(0, limit);
return items.map(block => {
const title = cleanGoogleNewsTitle(tag(block, 'title'));
const link = tag(block, 'link') || (block.match(/<link[^>]*>([^<]+)<\/link>/i)?.[1] || '').trim();
const pub = tag(block, 'pubDate');
const { anchorText, fontPub } = parseGoogleRssDescription(tag(block, 'description'));
const sourceName = cleanNewsPlain(tag(block, 'source'));
const publisher = sourceName || fontPub || 'Google 新聞';
let description = '';
if (anchorText && anchorText !== title && anchorText.length > 6 && !/news\.google\.com/i.test(anchorText)) {
description = anchorText;
}
return normalizeNewsItem({
title,
titleZh: title,
description: description.slice(0, 400),
descriptionZh: description.slice(0, 400),
url: link,
publisher,
created: pub ? new Date(pub).toISOString().slice(0, 10) : null,
region,
source: region === 'tw' ? 'Google 新聞(台灣)' : 'Google 新聞(國際)',
});
}).filter(n => n.titleZh && n.url);
}
export async function fetchTaiwanNews(symbol, companyName) {
const queries = [
/NVDA/i.test(symbol) ? '輝達' : null,
`${symbol} 台股`,
`${symbol} 美股`,
companyName && /[\u4e00-\u9fff]/.test(companyName) ? companyName : null,
].filter(Boolean);
const seen = new Set();
const out = [];
for (const q of queries) {
try {
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=zh-TW&gl=TW&ceid=TW:zh-Hant`;
const xml = await text(url, { Accept: 'application/rss+xml, application/xml, text/xml, */*' }, 10000);
for (const item of parseGoogleRss(xml, 'tw', 15)) {
const key = item.url;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
} catch { /* next query */ }
if (out.length >= 12) break;
}
return out.slice(0, 12);
}
export async function fetchGlobalNews(symbol) {
const out = [];
const seen = new Set();
try {
const yNews = await yahooFinanceSearchNews(symbol, 14);
for (const n of yNews) {
const item = normalizeNewsItem({
title: n.title,
titleZh: n.title,
description: strip(n.summary || ''),
descriptionZh: strip(n.summary || ''),
url: n.link,
publisher: n.publisher || 'Yahoo Finance',
created: n.providerPublishTime ? new Date(n.providerPublishTime * 1000).toISOString().slice(0, 10) : null,
region: 'global',
source: 'Yahoo Finance',
});
if (item.url && !seen.has(item.url)) { seen.add(item.url); out.push(item); }
}
} catch { /* */ }
try {
const y = await json(`https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}&newsCount=12&quotesCount=0`);
for (const n of y.news || []) {
const item = normalizeNewsItem({
title: n.title,
titleZh: n.title,
description: strip(n.summary || ''),
descriptionZh: strip(n.summary || ''),
url: n.link,
publisher: n.publisher || 'Yahoo Finance',
created: n.providerPublishTime ? new Date(n.providerPublishTime * 1000).toISOString().slice(0, 10) : null,
region: 'global',
source: 'Yahoo Finance',
});
if (item.url && !seen.has(item.url)) { seen.add(item.url); out.push(item); }
}
} catch { /* */ }
for (const q of [`${symbol} stock`, `${symbol} earnings CEO`]) {
try {
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=en-US&gl=US&ceid=US:en`;
const xml = await text(url, {}, 10000);
for (const item of parseGoogleRss(xml, 'global', 10)) {
if (seen.has(item.url)) continue;
seen.add(item.url);
out.push(item);
}
} catch { /* */ }
if (out.length >= 14) break;
}
try {
const d = await json(`https://api.nasdaq.com/api/news/topic/articlebysymbol?q=${encodeURIComponent(symbol)}|stocks&offset=0&limit=8&fallback=true`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
});
for (const r of d?.data?.rows || []) {
const url = r.url ? (r.url.startsWith('http') ? r.url : `https://www.nasdaq.com${r.url}`) : null;
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(normalizeNewsItem({
title: r.title,
titleZh: r.title,
description: strip(r.description || ''),
descriptionZh: strip(r.description || ''),
url,
publisher: r.publisher || 'Nasdaq',
created: r.created || r.ago,
region: 'global',
source: 'Nasdaq',
}));
}
} catch { /* */ }
return out.slice(0, 14);
}
let _tickerMap = null;
async function tickerToCik(symbol) {
if (!_tickerMap) {
const d = await json('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA });
_tickerMap = {};
for (const k of Object.keys(d)) _tickerMap[String(d[k].ticker).toUpperCase()] = { cik: String(d[k].cik_str).padStart(10, '0'), name: d[k].title };
}
return _tickerMap[symbol] || null;
}
export async function fetchCompanyProfileExtended(symbol, seed = {}) {
if (seed.longBusinessSummary && seed.sector) {
return {
symbol,
longBusinessSummary: seed.longBusinessSummary,
website: seed.website || null,
sector: seed.sector,
industry: seed.industry || null,
country: seed.country || null,
employees: seed.fullTimeEmployees ?? null,
peers: seed.peers || [],
source: seed.source || 'Yahoo assetProfile',
};
}
let profile = { symbol, longBusinessSummary: null, website: null, sector: null, industry: null, country: null, employees: null, peers: [] };
try {
const d = await yahooQuoteSummary(symbol, 'assetProfile,summaryProfile,peer');
const p = d?.assetProfile || {};
const sp = d?.summaryProfile || {};
const peers = (d?.peer?.symbols || [])
.map(s => String(s).split('.').pop()?.toUpperCase()).filter(s => s && s !== symbol);
profile = {
symbol,
longBusinessSummary: p.longBusinessSummary || sp.longBusinessSummary || null,
website: p.website || sp.website || null,
sector: p.sector || sp.sector || null,
industry: p.industry || sp.industry || null,
country: p.country || sp.country || null,
employees: p.fullTimeEmployees ?? sp.fullTimeEmployees ?? null,
peers: [...new Set(peers)].slice(0, 12),
source: 'Yahoo quoteSummary',
};
} catch { /* */ }
return profile;
}
function extractNamedEntities(section) {
const names = new Set();
const patterns = [
/(?:customers?|clients?|suppliers?|competitors?|partners?)[^.]{0,400}/gi,
/\b([A-Z][A-Za-z0-9&.\- ]{2,40}(?:Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.))/g,
];
for (const re of patterns) {
for (const m of section.matchAll(re)) {
const chunk = m[1] || m[0];
const hits = chunk.match(/\b([A-Z][A-Za-z0-9&.\- ]{2,35}(?:Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.))/g) || [];
for (const h of hits) {
const n = h.trim();
if (n.length > 3 && n.length < 50) names.add(n);
}
}
}
return [...names].slice(0, 15);
}
function extract10kSuppliers(plain) {
const names = new Set();
const chunks = [
plain.match(/(?:suppliers?|supply\s+chain|sole\s+supplier|third[- ]party\s+manufactur)[^.]{0,2000}/gi) || [],
plain.match(/(?:we\s+(?:rely|depend)\s+(?:on|upon)\s+)[^.]{0,800}/gi) || [],
plain.match(/(?:contract\s+manufactur|foundry)[^.]{0,1200}/gi) || [],
].flat();
for (const block of chunks) {
for (const n of extractNamedEntities(block)) names.add(n);
for (const m of block.matchAll(/\b(TSMC|Taiwan Semiconductor|Samsung|SK\s*Hynix|Micron|ASML|Synopsys|Cadence|Foxconn|Hon\s*Hai)\b/gi)) {
names.add(m[1].trim());
}
}
return [...names].slice(0, 18);
}
function extract10kCustomers(plain) {
const names = new Set();
const chunks = plain.match(/(?:major\s+customers?|principal\s+customers?|customers?\s+include|accounted\s+for\s+\d+%)[^.]{0,2000}/gi) || [];
for (const block of chunks) {
for (const n of extractNamedEntities(block)) names.add(n);
for (const m of block.matchAll(/\b(Microsoft|Amazon|Google|Alphabet|Meta|Apple|Tesla|Oracle)\b/gi)) {
names.add(m[1].trim());
}
for (const m of block.matchAll(/\b(Dell\s+Technologies|Hewlett[\s-]?Packard\s+Enterprise|Super\s*Micro\s+Computer|Lenovo|Cisco)\b/gi)) {
names.add(m[1].trim());
}
}
return [...names].slice(0, 18);
}
export async function fetch10kChainHints(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return { excerpt: null, customers: [], suppliers: [], competitors: [] };
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
let accn = null;
let primary = null;
for (let i = 0; i < (f.form || []).length; i++) {
if (f.form[i] === '10-K') {
accn = f.accessionNumber[i];
primary = f.primaryDocument?.[i];
break;
}
}
if (!accn || !primary) return { excerpt: null, customers: [], suppliers: [], competitors: [] };
const accNo = accn.replace(/-/g, '');
const url = `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`;
const html = await text(url, { 'User-Agent': SEC_UA }, 28000);
const plain = strip(html).slice(0, 180000);
const custSec = plain.match(/(?:major customers?|principal customers?|customers? include)[^.]{0,1200}/i)?.[0] || '';
const supSec = plain.match(/(?:suppliers?|supply chain|manufacturing)[^.]{0,1200}/i)?.[0] || '';
const compSec = plain.match(/(?:competition|competitors?)[^.]{0,1200}/i)?.[0] || '';
const bizSec = plain.match(/(?:business overview|description of business)[^.]{0,2500}/i)?.[0] || plain.slice(0, 2500);
const customers = [...new Set([...extractNamedEntities(custSec), ...extract10kCustomers(plain)])];
const suppliers = [...new Set([...extractNamedEntities(supSec), ...extract10kSuppliers(plain)])];
return {
excerpt: bizSec.slice(0, 2000),
customers,
suppliers,
competitors: extractNamedEntities(compSec),
source: 'SEC 10-K',
filingUrl: url,
companyName: hit.name,
};
}
const MGMT_KW = /chief executive|ceo|cfo|coo|president|board|director|executive|resign|appoint|compensation|guidance|layoff|restructur|merger|acquisition|investigation|subpoena|執行長|財務長|董事|人事|裁員|併購|收購|指引|調查/i;
export function filterManagementNews(news) {
return (news || []).filter(n => MGMT_KW.test(`${n.title} ${n.description}`)).slice(0, 10);
}
export async function fetchRecent8kHeadlines(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
const out = [];
for (let i = 0; i < (f.form || []).length && out.length < 8; i++) {
if (!/^8-K/i.test(f.form[i])) continue;
out.push({
form: f.form[i],
filedDate: f.filingDate[i],
description: f.primaryDocDescription?.[i] || '',
accession: f.accessionNumber[i],
url: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${hit.cik}&type=8-K&dateb=&owner=include&count=40`,
});
}
return out;
}
export async function gatherIntelSources(symbol, profile = {}) {
symbol = String(symbol || '').trim().toUpperCase();
const [profileExt, hints, headlines] = await Promise.all([
fetchCompanyProfileExtended(symbol, profile).catch(() => ({})),
fetch10kChainHints(symbol).catch(() => ({})),
fetchRecent8kHeadlines(symbol).catch(() => []),
]);
const companyName = profile.name || profile.companyName || hints?.companyName || null;
const [newsTw, newsGlobal] = await Promise.all([
fetchTaiwanNews(symbol, companyName).catch(() => []),
fetchGlobalNews(symbol).catch(() => []),
]);
const mgmtRaw = filterManagementNews([...newsTw, ...newsGlobal]);
return {
symbol,
gatheredAt: new Date().toISOString(),
profileExt,
hints,
headlines8k: headlines,
newsTw,
newsGlobal,
managementNewsRaw: mgmtRaw,
companyName: companyName || hints?.companyName || profileExt?.symbol,
};
}

View File

@ -1,30 +1,6 @@
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈 // companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈入口
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
import { getCompanyIntelCustom, getCompanyIntelEnriched } from './db.js';
import { localizeIntel, mergeCustomIntel, sanitizeOfficers, isOfficerRow, looksLikePersonName, looksLikeExecutiveTitle } from './companyintel-i18n.js';
import { gatherIntelSources, fetch10kChainHints } from './companyintel-sources.js';
import { mergeIndustryChainWithHints, buildCompanyResources } from './companyintel-links.js';
import {
mergeNewsIntoChain, finalizeIndustryChain, layoutPeersIntoGrid, sanitizeChainExcerpt, ensureDownstreamBuyers,
} from './companyintel-chain.js';
import { applyEnrichedToIntel, syncCompanyIntelEnriched, attachIntelSyncStatus } from './companyintel-ai.js';
import { normalizeNewsList } from './news-text.js';
/** API 快取命中時仍清理新聞欄位(舊快取可能含 Google RSS 跳脫 HTML */
export function sanitizeIntelNewsPayload(payload) {
if (!payload || typeof payload !== 'object') return payload;
const newsTw = normalizeNewsList(payload.newsTw);
const newsGlobal = normalizeNewsList(payload.newsGlobal);
return {
...payload,
newsTw,
newsGlobal,
news: normalizeNewsList(payload.news?.length ? payload.news : [...newsTw, ...newsGlobal]).slice(0, 20),
};
}
import { yahooQuoteSummary, resetYahooAuth, sleep } from './yahoo-session.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36'; const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)'; const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
@ -46,7 +22,7 @@ const num = (s) => {
const n = Number(String(s).replace(/[$,%\s,]/g, '')); const n = Number(String(s).replace(/[$,%\s,]/g, ''));
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
}; };
const tag = (src, name) => src.match(new RegExp(`<${name}>([\\s\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || null; const tag = (src, name) => src.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || null;
let _tickerMap = null; let _tickerMap = null;
async function tickerToCik(symbol) { async function tickerToCik(symbol) {
@ -58,23 +34,35 @@ async function tickerToCik(symbol) {
return _tickerMap[symbol] || null; return _tickerMap[symbol] || null;
} }
let _auth = { cookie: null, crumb: null, at: 0 };
async function yahooAuth() {
if (_auth.crumb && Date.now() - _auth.at < 3600e3) return _auth;
const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null);
const cookie = (r1 && (r1.headers.get('set-cookie') || '')).split(';')[0] || '';
const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', { headers: { 'User-Agent': UA, Cookie: cookie } });
const crumb = (await r2.text()).trim();
if (!crumb || crumb.includes('<')) throw new Error('無法取得 Yahoo crumb');
_auth = { cookie, crumb, at: Date.now() };
return _auth;
}
async function fetchManagement(symbol) { async function fetchManagement(symbol) {
try { try {
const r = await yahooQuoteSummary(symbol, 'assetProfile'); const { cookie, crumb } = await yahooAuth();
const p = r?.assetProfile || {}; const d = await json(`https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=assetProfile&crumb=${encodeURIComponent(crumb)}`, { Cookie: cookie });
const p = d?.quoteSummary?.result?.[0]?.assetProfile || {};
return { return {
sector: p.sector || null, sector: p.sector || null,
industry: p.industry || null, industry: p.industry || null,
website: p.website || null, website: p.website || null,
fullTimeEmployees: p.fullTimeEmployees ?? null, fullTimeEmployees: p.fullTimeEmployees ?? null,
longBusinessSummary: p.longBusinessSummary || null, officers: (p.companyOfficers || []).slice(0, 10).map(o => ({
officers: sanitizeOfficers((p.companyOfficers || []).slice(0, 12).map(o => ({
name: o.name || '', name: o.name || '',
title: o.title || '', title: o.title || '',
age: o.age ?? null, age: o.age ?? null,
fiscalYear: o.fiscalYear ?? null, fiscalYear: o.fiscalYear ?? null,
totalPay: o.totalPay?.raw ?? null, totalPay: o.totalPay?.raw ?? null,
}))).filter(o => o.name), })),
source: 'Yahoo assetProfile', source: 'Yahoo assetProfile',
}; };
} catch { } catch {
@ -82,125 +70,6 @@ async function fetchManagement(symbol) {
} }
} }
/** Yahoo 限流時重試;仍失敗則用 SEC 10-K僅美股 */
async function resolveManagement(symbol) {
let m = await fetchManagement(symbol);
if ((m.officers || []).length >= 2) return m;
await sleep(800);
resetYahooAuth();
const retry = await fetchManagement(symbol);
if ((retry.officers || []).length > (m.officers || []).length) m = retry;
if ((m.officers || []).length >= 2) return m;
const secOfficers = sanitizeOfficers(await fetchOfficersFromSec10k(symbol).catch(() => []));
if (secOfficers.length) {
return { ...m, officers: secOfficers, source: 'SEC 10-K' };
}
const defOfficers = await fetchOfficersFromDef14a(symbol).catch(() => []);
if (defOfficers.length) {
return { ...m, officers: defOfficers, source: 'SEC DEF 14A' };
}
return m;
}
/** 從股東會說明書DEF 14A抓高管Yahoo10-K 都失敗時用(例如 AAPL */
async function fetchOfficersFromDef14a(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
let accn = null;
let primary = null;
for (let i = 0; i < (f.form || []).length; i++) {
if (f.form[i] === 'DEF 14A') {
accn = f.accessionNumber[i];
primary = f.primaryDocument?.[i];
break;
}
}
if (!accn || !primary) return [];
const accNo = accn.replace(/-/g, '');
const html = await text(`https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`, { 'User-Agent': SEC_UA }, 28000);
const uniq = new Map();
const addPair = (name, title) => {
if (!isOfficerRow(name, title)) return;
uniq.set(name.toLowerCase(), { name, title: stripHtml(title), source: 'SEC DEF 14A' });
};
const election = html.match(/Election of Directors:\s*([^<]{20,400})/i)?.[1];
if (election) {
for (const name of election.split(',').map(s => stripHtml(s)).filter(Boolean)) {
if (!looksLikePersonName(name)) continue;
addPair(name, 'Director');
}
}
for (const label of ['Chief Executive Officer', 'Chief Financial Officer', 'Chief Operating Officer', 'Senior Vice President', 'General Counsel']) {
let idx = 0;
while (uniq.size < 14) {
idx = html.indexOf(label, idx);
if (idx < 0) break;
const before = stripHtml(html.slice(Math.max(0, idx - 160), idx));
const nameM = before.match(/([A-Z][a-z]+(?:\s+[A-Z]\.?)?\s+[A-Z][a-z]+)\s*$/);
if (nameM) addPair(nameM[1], label);
idx += label.length;
}
}
for (const name of [...uniq.keys()]) {
const display = uniq.get(name).name;
const pos = html.indexOf(display);
if (pos < 0) continue;
const chunk = html.slice(pos, pos + 520);
const titleM = chunk.match(/((?:Former\s+)?(?:Senior|Executive|Chief|General)[\s\S]{8,120}?)(?=\s*<p|\s*<td|\s*<div|$)/i);
const better = stripHtml(titleM?.[1] || '');
if (better && looksLikeExecutiveTitle(better)) {
uniq.set(name, { name: display, title: better, source: 'SEC DEF 14A' });
}
}
return sanitizeOfficers([...uniq.values()].filter(o => o.title !== 'Director' || uniq.size <= 4).slice(0, 12));
}
const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, ' ').replace(/&#160;/g, ' ').replace(/\s+/g, ' ').trim();
async function fetchOfficersFromSec10k(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
let accn = null;
let primary = null;
for (let i = 0; i < (f.form || []).length; i++) {
if (f.form[i] === '10-K') {
accn = f.accessionNumber[i];
primary = f.primaryDocument?.[i];
break;
}
}
if (!accn || !primary) return [];
const accNo = accn.replace(/-/g, '');
const url = `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${primary}`;
const html = await text(url, { 'User-Agent': SEC_UA }, 25000);
const item10 = html.search(/Item\s*10[\s\S]{0,120}(Executive Officers|Directors)/i);
const slice = item10 >= 0 ? html.slice(item10, item10 + 120000) : html.slice(0, 120000);
const rows = [...slice.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map(m => m[1]);
const officers = [];
for (const row of rows) {
const cells = [...row.matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi)].map(m => stripHtml(m[1]));
if (cells.length < 2) continue;
const title = cells.find(c => /Chief|President|Officer|Counsel|Operations|Financial|Accounting|Field/i.test(c) && c.length < 140);
const name = cells.find(c =>
c.length > 3 && c.length < 70
&& !/Chief|President|Officer|Director|Age|Name|Title|NVIDIA|Common|Stock|Item|Action/i.test(c)
&& /[A-Za-z]/.test(c),
);
if (!isOfficerRow(name, title)) continue;
officers.push({ name, title, source: 'SEC 10-K' });
}
const uniq = new Map();
for (const o of officers) {
const key = o.name.toLowerCase();
if (!uniq.has(key)) uniq.set(key, o);
}
return [...uniq.values()].slice(0, 12);
}
function parseForm4(txt, filing) { function parseForm4(txt, filing) {
const xml = txt.slice(txt.indexOf('<ownershipDocument')); const xml = txt.slice(txt.indexOf('<ownershipDocument'));
const ownerBlock = xml.match(/<reportingOwner>([\s\S]*?)<\/reportingOwner>/i)?.[1] || ''; const ownerBlock = xml.match(/<reportingOwner>([\s\S]*?)<\/reportingOwner>/i)?.[1] || '';
@ -231,7 +100,6 @@ function parseForm4(txt, filing) {
url: filing.url, url: filing.url,
}; };
} }
async function fetchInsiderTransactions(symbol) { async function fetchInsiderTransactions(symbol) {
const hit = await tickerToCik(symbol); const hit = await tickerToCik(symbol);
if (!hit) return []; if (!hit) return [];
@ -256,210 +124,81 @@ async function fetchInsiderTransactions(symbol) {
return out; return out;
} }
function industryChainFallback(symbol, profile = {}) { async function fetchNews(symbol) {
const d = await json(`https://api.nasdaq.com/api/news/topic/articlebysymbol?q=${encodeURIComponent(symbol)}|stocks&offset=0&limit=8&fallback=true`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
}).catch(() => null);
return (d?.data?.rows || []).slice(0, 8).map(r => ({
title: r.title,
publisher: r.publisher,
created: r.created || r.ago,
description: strip(r.description || ''),
url: r.url ? (r.url.startsWith('http') ? r.url : `https://www.nasdaq.com${r.url}`) : null,
relatedSymbols: (r.related_symbols || []).map(x => String(x).split('|')[0].toUpperCase()).filter(Boolean),
}));
}
function industryChain(symbol, profile = {}) {
const industry = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase(); const industry = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase();
const maps = [ const maps = [
{ {
match: /semiconductor|chip|accelerated|technology/, match: /semiconductor|chip|accelerated|technology/,
upstream: ['EDA/IP 軟體', '晶圓代工', '先進封裝', 'HBM/記憶體', '半導體設備', 'ABF/載板'], upstream: ['EDA/IP 軟體', '晶圓代工', '先進封裝', 'HBM/記憶體', '半導體設備', 'ABF/載板'],
upstreamNamed: ['TSM', 'ASML', 'AMAT', 'LRCX', 'KLAC', 'MU', 'SNPS', 'CDNS'],
peers: ['AMD', 'AVGO', 'QCOM', 'MRVL', 'TSM', 'ASML', 'MU'], peers: ['AMD', 'AVGO', 'QCOM', 'MRVL', 'TSM', 'ASML', 'MU'],
downstream: ['雲端資料中心', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'], downstream: ['雲端資料中心', 'AI 伺服器 OEM/ODM', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'],
downstreamNamed: [
{ label: 'AI 伺服器 OEM', entities: ['DELL', 'HPE', 'SMCI'], note: '採購 GPU 組裝銷售' },
{ label: '雲端與大型企業', entities: ['MSFT', 'AMZN', 'GOOGL', 'META'], note: '資料中心 GPU 需求' },
],
midstream: { role: '晶片設計GPU 平台', segments: ['資料中心 GPU', '遊戲 GPU', '軟體 CUDA'] },
}, },
{ {
match: /software|internet|communication|media/, match: /software|internet|communication|media/,
upstream: ['雲端基礎設施', '資料中心', '廣告技術', '內容/資料供應商'], upstream: ['雲端基礎設施', '資料中心', '廣告技術', '內容/資料供應商'],
peers: ['MSFT', 'GOOGL', 'META', 'AMZN', 'CRM', 'ORCL'], peers: ['MSFT', 'GOOGL', 'META', 'AMZN', 'CRM', 'ORCL'],
downstream: ['企業客戶', '消費者流量', '開發者生態', '廣告主'], downstream: ['企業客戶', '消費者流量', '開發者生態', '廣告主'],
midstream: { role: '軟體/平台', segments: ['訂閱', '廣告', '雲端服務'] }, },
{
match: /consumer|retail|apparel/,
upstream: ['原物料', '製造代工', '物流倉儲', '通路平台'],
peers: ['AMZN', 'WMT', 'COST', 'TGT', 'NKE'],
downstream: ['消費者', '會員訂閱', '門市/電商通路'],
}, },
]; ];
const hit = maps.find(m => m.match.test(industry)) || { const hit = maps.find(m => m.match.test(industry)) || {
upstream: ['原物料/零組件', '設備與服務供應商'], upstream: ['原物料/零組件', '設備與服務供應商', '物流與通路', '資本支出供應商'],
upstreamNamed: [],
peers: [], peers: [],
downstream: ['終端客戶', '企業採購', '通路夥伴'], downstream: ['終端客戶', '企業採購', '通路夥伴', '替代產品'],
downstreamNamed: [],
midstream: { role: profile.industry || '核心業務', segments: [] },
}; };
const upDetail = hit.upstreamNamed?.length const q = encodeURIComponent(`${symbol} suppliers customers upstream downstream competitors`);
? [{ label: '供應商', entities: hit.upstreamNamed, note: '產業鏈慣例' },
...hit.upstream.map(u => ({ label: u, entities: [u], note: '' }))]
: hit.upstream.map(u => ({ label: u, entities: [u], note: '' }));
return { return {
upstream: hit.upstream, upstream: hit.upstream,
upstreamDetail: upDetail,
downstream: hit.downstream,
downstreamDetail: (hit.downstreamNamed?.length
? hit.downstreamNamed.map(d => ({
label: d.label || '購買方',
entities: d.entities || [],
note: d.note || '產業鏈慣例',
}))
: []).concat(hit.downstream.map(d => ({ label: d, entities: [d], note: '' }))),
peers: hit.peers.filter(s => s !== symbol), peers: hit.peers.filter(s => s !== symbol),
downstream: hit.downstream,
searches: [
{ label: '供應商 / 客戶', url: `https://www.google.com/search?q=${q}` },
{ label: '10-K supply chain', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} 10-K suppliers customers supply chain`)}` },
{ label: '同業比較', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} competitors industry peers`)}` },
],
}; };
} }
/** 完整同步:多來源新聞 + AI 結構化 + 寫入 DB */ export async function getCompanyIntel(symbol, profile = {}) {
export async function runCompanyIntelSync(symbol, profile = {}, opts = {}) {
const management = await resolveManagement(symbol);
return syncCompanyIntelEnriched(symbol, { ...profile, ...management }, {
force: opts.force === true,
useAI: opts.useAI !== false,
management,
});
}
function buildDataHealth(fields) {
const notes = [];
if (!fields.officers) notes.push('管理層名單未取得(可按「強制更新」重試)');
if (!fields.newsTw && !fields.newsGlobal) notes.push('新聞來源暫時無回應');
if (!fields.insiders && fields.usListing) notes.push('近期無 SEC Form 4 或 CIK 對應失敗');
if (!fields.insiders && !fields.usListing) notes.push('非美股標的,無 SEC 內部人申報');
if (!fields.profileDesc) notes.push('公司簡介待同步後整理為中文');
return { ...fields, notes };
}
export async function getCompanyIntel(symbol, profile = {}, opts = {}) {
symbol = String(symbol || '').trim().toUpperCase(); symbol = String(symbol || '').trim().toUpperCase();
const management = await resolveManagement(symbol); const [management, insiders, news] = await Promise.all([
const usListing = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.'); fetchManagement(symbol),
fetchInsiderTransactions(symbol).catch(() => []),
let bundle = null; fetchNews(symbol).catch(() => []),
let enrichedRow = getCompanyIntelEnriched(symbol); ]);
if (opts.sync) { return {
const sync = await runCompanyIntelSync(symbol, { ...profile, ...management }, { force: opts.force, useAI: opts.useAI });
bundle = sync.bundle;
enrichedRow = { data: sync.enriched, sources: sync.sources, updatedAt: Date.now() };
} else if (!enrichedRow) {
bundle = await gatherIntelSources(symbol, { ...profile, name: profile.name, ...management }).catch(() => null);
}
const insiders = usListing
? await fetchInsiderTransactions(symbol).catch(() => [])
: [];
let newsTw = bundle?.newsTw || [];
let newsGlobal = bundle?.newsGlobal || [];
if (!newsTw.length && !newsGlobal.length && !opts.sync) {
const b = await gatherIntelSources(symbol, { ...profile, ...management }).catch(() => null);
if (b) {
newsTw = b.newsTw || [];
newsGlobal = b.newsGlobal || [];
bundle = b;
}
}
const custom = getCompanyIntelCustom(symbol);
let industryChain = industryChainFallback(symbol, { ...profile, ...management });
const hints = bundle?.hints || (usListing && !opts.sync
? await fetch10kChainHints(symbol).catch(() => ({}))
: {});
if (hints && Object.keys(hints).length) {
industryChain = mergeIndustryChainWithHints(
symbol,
industryChain,
hints,
bundle?.profileExt || {},
{ ...profile, ...management },
);
}
let profileZh = management.longBusinessSummary
? { description: management.longBusinessSummary.slice(0, 500), businessModel: management.industry || profile.industry || '' }
: (bundle?.profileExt?.longBusinessSummary
? { description: bundle.profileExt.longBusinessSummary.slice(0, 500), businessModel: bundle.profileExt.industry || '' }
: null);
const raw = {
symbol, symbol,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
profileZh, management: {
management: { ...management, searches: [] }, ...management,
searches: [
{ label: '管理層 / Leadership', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} executive officers management leadership`)}` },
{ label: 'Proxy / DEF 14A', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} DEF 14A executive compensation board directors`)}` },
{ label: 'Investor relations', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} investor relations leadership`)}` },
],
},
insiders, insiders,
news: normalizeNewsList([...newsTw, ...newsGlobal]).slice(0, 20), news,
newsTw: normalizeNewsList(newsTw), industryChain: industryChain(symbol, { ...profile, ...management }),
newsGlobal: normalizeNewsList(newsGlobal), sources: ['Yahoo assetProfile', 'SEC Form 4', 'Nasdaq News'],
managementBrief: (bundle?.managementNewsRaw || []).slice(0, 6).map(n => ({
date: n.created,
headline: n.titleZh || n.title,
summary: (n.descriptionZh || n.description || '').slice(0, 160),
impact: 'neutral',
source: n.publisher,
url: n.url,
})),
industryChain,
sources: [
management.source || 'Yahoo assetProfile',
'SEC Form 4',
'Google 新聞(台灣)',
'Google 新聞(國際)',
'Nasdaq / Yahoo Finance',
...(enrichedRow?.sources || []),
...(custom ? ['本機自訂'] : []),
].filter(Boolean),
customUpdatedAt: custom?.updatedAt ? new Date(custom.updatedAt).toISOString() : null,
enrichedAt: enrichedRow?.updatedAt ? new Date(enrichedRow.updatedAt).toISOString() : null,
aiEnriched: enrichedRow?.data?.aiUsed || false,
enrichSources: enrichedRow?.sources || [],
}; };
let intel = mergeCustomIntel(localizeIntel(raw), custom?.data);
if (enrichedRow?.data) {
intel = applyEnrichedToIntel(intel, { ...enrichedRow.data, sources: enrichedRow.sources });
}
intel.newsTw = normalizeNewsList(intel.newsTw);
intel.newsGlobal = normalizeNewsList(intel.newsGlobal);
intel.news = normalizeNewsList(intel.news?.length ? intel.news : [...intel.newsTw, ...intel.newsGlobal]).slice(0, 20);
if (hints && Object.keys(hints).length) {
intel.industryChain = mergeIndustryChainWithHints(
symbol,
intel.industryChain,
hints,
bundle?.profileExt || {},
{ ...profile, ...management },
);
}
const allNews = [...(intel.newsTw || []), ...(intel.newsGlobal || []), ...(intel.news || [])];
intel.industryChain = ensureDownstreamBuyers(
layoutPeersIntoGrid(
finalizeIndustryChain(mergeNewsIntoChain(intel.industryChain, allNews, symbol), symbol),
symbol,
),
symbol,
{ ...profile, ...management },
);
if (intel.industryChain.tenKExcerpt) {
intel.industryChain.tenKExcerpt = sanitizeChainExcerpt(intel.industryChain.tenKExcerpt);
}
const resources = usListing
? await buildCompanyResources(symbol, { ...profile, website: management.website }, management).catch(() => [])
: [];
if (hints?.filingUrl) {
resources.unshift({ labelZh: '10-K 年報全文', url: hints.filingUrl, source: 'SEC' });
}
const seenUrl = new Set();
intel.resources = resources.filter(l => {
if (!l?.url || seenUrl.has(l.url)) return false;
seenUrl.add(l.url);
return true;
});
intel.management = { ...intel.management, searches: [], resources };
intel.chainLayout = enrichedRow?.data?.chainLayout || 'upstream_downstream_v2';
intel = attachIntelSyncStatus(intel, symbol);
intel.dataHealth = buildDataHealth({
officers: (intel.management?.officers || []).length > 0,
newsTw: (intel.newsTw || []).length > 0,
newsGlobal: (intel.newsGlobal || []).length > 0,
insiders: insiders.length > 0,
profileDesc: !!(intel.profileZh?.description?.length > 40),
enriched: !!(intel.enrichedAt || intel.aiEnriched),
usListing,
});
return sanitizeIntelNewsPayload(intel);
} }

249
lib/db.js
View File

@ -32,70 +32,6 @@ db.exec(`
score INTEGER NOT NULL, score INTEGER NOT NULL,
regime TEXT regime TEXT
); );
CREATE TABLE IF NOT EXISTS price_bars (
symbol TEXT NOT NULL,
interval TEXT NOT NULL DEFAULT '1d',
date TEXT NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
adjclose REAL,
PRIMARY KEY (symbol, interval, date)
);
CREATE INDEX IF NOT EXISTS idx_price_bars_sym ON price_bars(symbol, interval, date);
CREATE TABLE IF NOT EXISTS company_intel_custom (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sec_filings (
symbol TEXT NOT NULL,
accession TEXT NOT NULL,
form TEXT,
form_zh TEXT,
filed_date TEXT,
report_date TEXT,
description TEXT,
primary_document TEXT,
url TEXT,
local_primary TEXT,
local_txt TEXT,
excerpt TEXT,
is_earnings_related INTEGER DEFAULT 0,
earnings_exhibits TEXT,
archived_at INTEGER,
PRIMARY KEY (symbol, accession)
);
CREATE INDEX IF NOT EXISTS idx_sec_filings_sym ON sec_filings(symbol, filed_date DESC);
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
event_date TEXT,
title TEXT,
title_zh TEXT,
time_label TEXT,
source TEXT,
url TEXT,
note TEXT,
kind TEXT,
accession TEXT,
transcript_search_url TEXT,
UNIQUE(symbol, event_date, kind, accession, title)
);
CREATE INDEX IF NOT EXISTS idx_earnings_sym ON earnings_events(symbol, event_date DESC);
CREATE TABLE IF NOT EXISTS sec_archive_meta (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS company_intel_enriched (
symbol TEXT PRIMARY KEY,
payload TEXT NOT NULL,
sources TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS trades ( CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
@ -164,77 +100,6 @@ export function getScoreHistory() {
return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all(); return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all();
} }
// ─── 個股 OHLCV 日線長期累積API 只補缺口)───
const upsertBar = db.prepare(`
INSERT INTO price_bars (symbol, interval, date, open, high, low, close, volume, adjclose)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, interval, date) DO UPDATE SET
open=COALESCE(excluded.open, open),
high=COALESCE(excluded.high, high),
low=COALESCE(excluded.low, low),
close=COALESCE(excluded.close, close),
volume=COALESCE(excluded.volume, volume),
adjclose=COALESCE(excluded.adjclose, adjclose)
`);
function normBarPoint(p) {
const close = p.close != null ? Number(p.close) : null;
if (close == null || isNaN(close)) return null;
const adj = p.adjclose != null ? Number(p.adjclose) : close;
const o = p.open != null ? Number(p.open) : close;
const h = p.high != null ? Number(p.high) : close;
const l = p.low != null ? Number(p.low) : close;
const vol = p.volume != null ? Number(p.volume) : null;
return { date: p.date, open: o, high: h, low: l, close, volume: vol, adjclose: adj };
}
export function upsertPriceBars(symbol, interval, points) {
if (!symbol || !points?.length) return 0;
let n = 0;
db.exec('BEGIN');
try {
for (const raw of points) {
const p = normBarPoint(raw);
if (!p) continue;
upsertBar.run(symbol, interval, p.date, p.open, p.high, p.low, p.close, p.volume, p.adjclose);
n++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
return n;
}
export function getPriceBars(symbol, interval = '1d', sinceISO = null) {
if (sinceISO) {
return db.prepare(
'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? AND date>=? ORDER BY date ASC',
).all(symbol, interval, sinceISO);
}
return db.prepare(
'SELECT date, open, high, low, close, volume, adjclose FROM price_bars WHERE symbol=? AND interval=? ORDER BY date ASC',
).all(symbol, interval);
}
export function deletePriceBars(symbol, interval = '1d') {
db.prepare('DELETE FROM price_bars WHERE symbol=? AND interval=?').run(symbol, interval);
}
export function getPriceBarMeta(symbol, interval = '1d') {
const row = db.prepare(
'SELECT COUNT(*) AS n, MIN(date) AS first_date, MAX(date) AS last_date FROM price_bars WHERE symbol=? AND interval=?',
).get(symbol, interval);
return row || { n: 0, first_date: null, last_date: null };
}
export function priceBarsToPoints(bars) {
return (bars || []).map(b => ({
date: b.date,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
adjclose: b.adjclose ?? b.close,
}));
}
// ─── 通用 JSON 快取(給財報健檢等,沿用 cache 表,含 TTL─── // ─── 通用 JSON 快取(給財報健檢等,沿用 cache 表,含 TTL───
export function putCachedJSON(key, value) { export function putCachedJSON(key, value) {
db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)') db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)')
@ -253,120 +118,6 @@ export function getCachedEntry(key) {
try { return { value: JSON.parse(row.payload), updatedAt: row.updated_at }; } catch { return null; } try { return { value: JSON.parse(row.payload), updatedAt: row.updated_at }; } catch { return null; }
} }
// ─── 價格走勢:自訂中文公司研究(管理層、新聞、簡介)───
export function getCompanyIntelCustom(symbol) {
const row = db.prepare('SELECT payload, updated_at FROM company_intel_custom WHERE symbol = ?').get(
String(symbol || '').trim().toUpperCase(),
);
if (!row) return null;
try {
return { data: JSON.parse(row.payload), updatedAt: row.updated_at };
} catch {
return null;
}
}
export function saveCompanyIntelCustom(symbol, data) {
const sym = String(symbol || '').trim().toUpperCase();
if (!sym) throw new Error('bad_symbol');
db.prepare('INSERT OR REPLACE INTO company_intel_custom (symbol, payload, updated_at) VALUES (?, ?, ?)')
.run(sym, JSON.stringify(data || {}), Date.now());
return { symbol: sym, updatedAt: Date.now() };
}
// ─── SEC 申報與財報/法說封存 ───
const upsertFilingStmt = db.prepare(`
INSERT INTO sec_filings (
symbol, accession, form, form_zh, filed_date, report_date, description,
primary_document, url, local_primary, local_txt, excerpt, is_earnings_related,
earnings_exhibits, archived_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, accession) DO UPDATE SET
form=excluded.form, form_zh=excluded.form_zh, filed_date=excluded.filed_date,
report_date=excluded.report_date, description=excluded.description,
primary_document=excluded.primary_document, url=excluded.url,
local_primary=COALESCE(excluded.local_primary, local_primary),
local_txt=COALESCE(excluded.local_txt, local_txt),
excerpt=COALESCE(excluded.excerpt, excerpt),
is_earnings_related=excluded.is_earnings_related,
earnings_exhibits=COALESCE(excluded.earnings_exhibits, earnings_exhibits),
archived_at=COALESCE(excluded.archived_at, archived_at)
`);
export function upsertSecFiling(row) {
const sym = String(row.symbol || '').trim().toUpperCase();
upsertFilingStmt.run(
sym, row.accession, row.form || null, row.formZh || null, row.filedDate || null,
row.reportDate || null, row.description || null, row.primaryDocument || null, row.url || null,
row.localPrimary || null, row.localTxt || null, row.excerpt || null,
row.isEarningsRelated ? 1 : 0, row.earningsExhibits || null, Date.now(),
);
}
export function listSecFilings(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
return db.prepare(
'SELECT symbol, accession, form, form_zh AS formZh, filed_date AS filedDate, report_date AS reportDate, description, primary_document AS primaryDocument, url, local_primary AS localPrimary, local_txt AS localTxt, excerpt, is_earnings_related AS isEarningsRelated, earnings_exhibits AS earningsExhibits, archived_at AS archivedAt FROM sec_filings WHERE symbol=? ORDER BY filed_date DESC, accession DESC',
).all(sym).map(r => ({
...r,
isEarningsRelated: !!r.isEarningsRelated,
earningsExhibits: r.earningsExhibits ? (() => { try { return JSON.parse(r.earningsExhibits); } catch { return null; } })() : null,
archivedAt: r.archivedAt ? new Date(r.archivedAt).toISOString() : null,
}));
}
const upsertEarnStmt = db.prepare(`
INSERT INTO earnings_events (symbol, event_date, title, title_zh, time_label, source, url, note, kind, accession, transcript_search_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, event_date, kind, accession, title) DO UPDATE SET
title_zh=excluded.title_zh, time_label=excluded.time_label, source=excluded.source,
url=COALESCE(excluded.url, url), note=excluded.note, transcript_search_url=excluded.transcript_search_url
`);
export function upsertEarningsEvent(row) {
const sym = String(row.symbol || '').trim().toUpperCase();
upsertEarnStmt.run(
sym, row.eventDate || null, row.title || null, row.titleZh || row.title || null,
row.timeLabel || '', row.source || null, row.url || null, row.note || '',
row.kind || 'calendar', row.accession || '', row.transcriptSearchUrl || null,
);
}
export function listEarningsEvents(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
return db.prepare(
'SELECT id, symbol, event_date AS eventDate, title, title_zh AS titleZh, time_label AS timeLabel, source, url, note, kind, accession, transcript_search_url AS transcriptSearchUrl FROM earnings_events WHERE symbol=? ORDER BY event_date DESC, id DESC LIMIT 80',
).all(sym);
}
export function getSecArchiveMeta(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
const row = db.prepare('SELECT payload, updated_at FROM sec_archive_meta WHERE symbol=?').get(sym);
if (!row) return null;
try {
const data = JSON.parse(row.payload);
return { ...data, lastSyncAt: data.lastSyncAt || row.updated_at };
} catch { return { lastSyncAt: row.updated_at }; }
}
export function saveSecArchiveMeta(symbol, data) {
const sym = String(symbol || '').trim().toUpperCase();
db.prepare('INSERT OR REPLACE INTO sec_archive_meta (symbol, payload, updated_at) VALUES (?, ?, ?)')
.run(sym, JSON.stringify(data || {}), Date.now());
}
export function getCompanyIntelEnriched(symbol) {
const sym = String(symbol || '').trim().toUpperCase();
const row = db.prepare('SELECT payload, sources, updated_at FROM company_intel_enriched WHERE symbol=?').get(sym);
if (!row) return null;
try {
return {
data: JSON.parse(row.payload),
sources: row.sources ? JSON.parse(row.sources) : [],
updatedAt: row.updated_at,
};
} catch {
return null;
}
}
export function saveCompanyIntelEnriched(symbol, data, sources = []) {
const sym = String(symbol || '').trim().toUpperCase();
db.prepare('INSERT OR REPLACE INTO company_intel_enriched (symbol, payload, sources, updated_at) VALUES (?, ?, ?, ?)')
.run(sym, JSON.stringify(data || {}), JSON.stringify(sources || []), Date.now());
}
// ─── 交易復盤 ─── // ─── 交易復盤 ───
const TRADE_FIELDS = ['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares', const TRADE_FIELDS = ['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares',
'exit_date', 'exit_price', 'entry_reason', 'exit_reason', 'principle', 'mistake', 'mistake_note', 'note']; 'exit_date', 'exit_price', 'entry_reason', 'exit_reason', 'principle', 'mistake', 'mistake_note', 'note'];

View File

@ -196,67 +196,10 @@ async function yahooAuth() {
_auth = { cookie, crumb, at: Date.now() }; _auth = { cookie, crumb, at: Date.now() };
return _auth; return _auth;
} }
function mapEarningsTrendPeriod(t) {
if (!t) return null;
const ee = t.earningsEstimate || {};
const rev = t.revenueEstimate || {};
const ebitda = t.ebitdaEstimate || {};
const growthPct = (g) => {
const v = num(g);
if (v == null) return null;
return Math.abs(v) <= 1.5 ? v * 100 : v;
};
return {
period: t.period,
endDate: t.endDate || null,
epsAvg: num(ee.avg),
epsLow: num(ee.low),
epsHigh: num(ee.high),
epsAnalysts: num(ee.numberOfAnalysts),
epsGrowthPct: growthPct(ee.growth),
revenueAvg: num(rev.avg),
revenueLow: num(rev.low),
revenueHigh: num(rev.high),
revenueAnalysts: num(rev.numberOfAnalysts),
revenueGrowthPct: growthPct(rev.growth),
ebitdaAvg: num(ebitda.avg),
ebitdaLow: num(ebitda.low),
ebitdaHigh: num(ebitda.high),
ebitdaAnalysts: num(ebitda.numberOfAnalysts),
};
}
function parseYahooEstimates(r) {
const trends = r.earningsTrend?.trend || [];
const byPeriod = {};
for (const t of trends) if (t?.period) byPeriod[t.period] = t;
const fd = r.financialData || {};
const sd = r.summaryDetail || {};
const dks = r.defaultKeyStatistics || {};
const currentYear = mapEarningsTrendPeriod(byPeriod['0y'] || byPeriod['+0y']);
const nextYear = mapEarningsTrendPeriod(byPeriod['+1y'] || byPeriod['1y']);
const hasConsensus = !!(currentYear?.epsAvg != null || currentYear?.revenueAvg != null
|| nextYear?.epsAvg != null || nextYear?.revenueAvg != null);
return {
source: 'Yahoo Finance',
endpoint: 'quoteSummary modules: earningsTrend, financialData, defaultKeyStatistics',
fetchedAt: new Date().toISOString(),
forwardEps: num(dks.forwardEps) ?? num(fd.forwardEps),
currentYear,
nextYear,
currentQuarter: mapEarningsTrendPeriod(byPeriod['0q']),
nextQuarter: mapEarningsTrendPeriod(byPeriod['+1q'] || byPeriod['1q']),
targetMean: num(fd.targetMeanPrice) ?? num(sd.targetMeanPrice),
targetLow: num(fd.targetLowPrice) ?? num(sd.targetLowPrice),
targetHigh: num(fd.targetHighPrice) ?? num(sd.targetHighPrice),
targetAnalysts: num(fd.numberOfAnalystOpinions),
hasConsensus,
};
}
async function fetchYahoo(symbol) { async function fetchYahoo(symbol) {
const { cookie, crumb } = await yahooAuth(); const { cookie, crumb } = await yahooAuth();
const mods = [ const mods = [
'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'earningsTrend', 'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData',
'incomeStatementHistory', 'incomeStatementHistoryQuarterly', 'incomeStatementHistory', 'incomeStatementHistoryQuarterly',
'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly', 'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly',
'balanceSheetHistoryQuarterly', 'balanceSheetHistoryQuarterly',
@ -322,7 +265,6 @@ async function fetchYahoo(symbol) {
debtToAssets: pct(totalLiabilities, totalAssets), debtToAssets: pct(totalLiabilities, totalAssets),
}; };
const estimates = parseYahooEstimates(r);
return { return {
source: 'Yahoo Finance', source: 'Yahoo Finance',
name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol, name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol,
@ -331,8 +273,6 @@ async function fetchYahoo(symbol) {
marketCap: num(r.price?.marketCap), marketCap: num(r.price?.marketCap),
sharesOutstanding: shares, sharesOutstanding: shares,
quarters, annual, balance, quarters, annual, balance,
estimates,
targetPrice: estimates.targetMean ?? num(r.financialData?.targetMeanPrice) ?? null,
}; };
} }
@ -461,7 +401,6 @@ export async function getFundamentals(symbol) {
if (!data) throw new Error('兩個來源都取不到財報(' + errs.join('') + ''); if (!data) throw new Error('兩個來源都取不到財報(' + errs.join('') + '');
const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null; const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null;
const targetPrice = priceInfo.targetPrice ?? data.targetPrice ?? data.estimates?.targetMean ?? null;
return { return {
symbol, symbol,
name: data.name || priceInfo.name || symbol, name: data.name || priceInfo.name || symbol,
@ -472,19 +411,10 @@ export async function getFundamentals(symbol) {
peTrailing: priceInfo.peTrailing ?? data.peTrailing ?? null, peTrailing: priceInfo.peTrailing ?? data.peTrailing ?? null,
marketCap: priceInfo.marketCap ?? data.marketCap ?? null, marketCap: priceInfo.marketCap ?? data.marketCap ?? null,
sharesOutstanding: priceInfo.sharesOutstanding ?? data.sharesOutstanding ?? ((priceInfo.marketCap && priceInfo.price) ? priceInfo.marketCap / priceInfo.price : null), sharesOutstanding: priceInfo.sharesOutstanding ?? data.sharesOutstanding ?? ((priceInfo.marketCap && priceInfo.price) ? priceInfo.marketCap / priceInfo.price : null),
targetPrice, targetPrice: priceInfo.targetPrice ?? null,
targetMeta: data.estimates ? {
mean: data.estimates.targetMean,
low: data.estimates.targetLow,
high: data.estimates.targetHigh,
analysts: data.estimates.targetAnalysts,
source: data.estimates.source,
endpoint: data.estimates.endpoint,
} : (priceInfo.targetPrice != null ? { source: priceInfo.source || 'Nasdaq summary', endpoint: 'api.nasdaq.com/.../summary' } : null),
dividendYield: priceInfo.dividendYield ?? null, dividendYield: priceInfo.dividendYield ?? null,
quarters: data.quarters || [], quarters: data.quarters || [],
annual: data.annual || [], annual: data.annual || [],
balance: data.balance || {}, balance: data.balance || {},
estimates: data.estimates || null,
}; };
} }

View File

@ -53,11 +53,6 @@ const TERM_TIPS = {
what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。', what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。',
how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。', how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。',
}, },
kdj: {
label: 'KDJ',
what: '隨機指標的變體:看收盤價在一段高低區間裡的位置,再平滑成 K、DJ3K2D。',
how: 'K、D 高於 80 常視為偏熱,低於 20 偏冷J 線波動較大,適合搭配趨勢與成交量一起看(本頁為 9,3,3。',
},
boll: { boll: {
label: '布林通道', label: '布林通道',
what: '在均線上下各畫一條「正常波動範圍」的界線,像橡皮筋包著股價。', what: '在均線上下各畫一條「正常波動範圍」的界線,像橡皮筋包著股價。',
@ -135,60 +130,23 @@ const TERM_TIPS = {
}, },
target_price: { target_price: {
label: '1 年目標價', label: '1 年目標價',
what: '賣方/聚合分析師對未來約 12 個月的平均目標股價(共識),不是 MacroScope 自己算的。', what: '券商分析師預測,這檔股票一年後「合理價位」大概在哪。',
how: '只是預測,常偏樂觀;請對照卡片下方備註的資料來源與區間。', how: '只是預測,常偏樂觀;當參考就好,不要當成一定會到的價格。',
formula: '顯示值 = 資料源提供的 targetMean或 Nasdaq OneYrTarget',
source: 'Yahoo financialData.targetMeanPrice備援 Nasdaq summary OneYrTarget',
},
est_revenue: {
label: '預估營收(共識)',
what: '多家分析師對某一財年或財季的營收平均預測consensus avg。',
how: '公布財報時常拿「實際 vs 這個 avg」比較 beat/miss。',
formula: '顯示值 = earningsTrend.revenueEstimate.avg優先下一財年 +1y否則本年 0y',
source: 'Yahoo quoteSummary · module=earningsTrend非官方 API可能延遲',
},
est_eps: {
label: '預估 EPS共識',
what: '分析師對每股盈餘的平均預測;也可對照 forwardEps通常為未來 12 個月)。',
how: 'EPS 預測會隨財報季更新;請看 ? 內的期間 endDate 與分析師人數。',
formula: '顯示值 = earningsTrend.earningsEstimate.avg備註可附 defaultKeyStatistics.forwardEps',
source: 'Yahoo quoteSummary · earningsTrend + defaultKeyStatistics',
},
est_ebitda: {
label: '預估 EBITDA共識',
what: '分析師對息稅折舊攤銷前獲利的共識預估(若 Yahoo 有提供該期 ebitdaEstimate。',
how: '沒有共識時不顯示數字,避免用錯模型冒充分析師預測。',
formula: '顯示值 = earningsTrend.ebitdaEstimate.avg',
source: 'Yahoo quoteSummary · earningsTrend',
},
growth_5y: {
label: '未來 5 年成長',
what: '可能是分析師對下一財年的營收/EPS 成長率,或本 App 用歷史營收算的 5 年 CAGR會標明。',
how: '兩者意義不同:共識 forward 只看一年左右;歷史 CAGR 只看過去。請以 ? 說明為準。',
formula: '共識revenueEstimate.growth 或 earningsEstimate.growth\n歷史推算CAGR = (Rev_end / Rev_start)^(1/年數) - 1',
source: '共識Yahoo earningsTrend歷史MacroScope 用 annual 營收(來自 Yahoo/EDGAR',
}, },
dcf: { dcf: {
label: 'DCF 公允價值', label: 'DCF 公允價值',
what: 'MacroScope 內建教學用 DCF把未來 5 年自由現金流折現,加終值,調整現金與負債,再除以流通股數。', what: '把公司未來可能賺到的現金,一筆一筆折現加總,估算「現在值多少錢」。',
how: '每檔股票的實際輸入數字與假設會寫在該卡片 ? 內;與分析師目標價無關。', how: '假設(成長率、折現率)一改,結果差很多;適合看區間,不適合當精準股價。',
formula: 'FCF₀ = OCF + CapEx\nPV = Σ FCF_t/(1+r)^t + Terminal/(1+r)^5\n每股 = (PV + 現金 - 負債) / 流通股數',
source: '輸入:財報 OCF/CapEx/現金/負債Yahoo 或 SEC EDGAR',
model: '成長率由營收/淨利年增、毛利率、ROE 啟發式調整;折現率 813%;終值成長 2.5%',
}, },
margin_of_safety: { margin_of_safety: {
label: '安全邊際', label: '安全邊際',
what: 'DCF 公允價值相對現價的溢價%;正值代表模型認為比現價便宜。', what: '估算的合理價值,比現在股價高多少%。代表「便宜緩衝」有多大。',
how: 'DCF 假設一變,這個數字也會變;請搭配 DCF 卡片 ? 內公式核對。', how: '正值代表現價低於估算值;負值代表可能偏貴。留安全邊際是為了估錯還有退路。',
formula: '安全邊際(%) = (DCF 每股公允價值 / 現價 - 1) × 100',
source: 'MacroScope 本機 DCF 輸出 ÷ 即時現價Yahoo/Nasdaq',
}, },
dcf_assumption: { dcf_assumption: {
label: '估值假設', label: '估值假設',
what: 'DCF 模型當次計算使用的 5 年 FCF 成長率、折現率、終值成長率。', what: 'DCF 裡你猜的:未來幾年成長多快、折現率(要求報酬)多少、長期成長率多少。',
how: '成長率會參考營收/淨利年增與 ROE/毛利率;波動與槓桿會調高折現率。', how: '假設越樂觀,算出來的價值越高;看這行是在提醒「這只是模型,不是真理」。',
formula: 'g = clamp(平均(營收年增, 淨利年增) + 品質加減分, -5%, 25%)\nr = 9% + 波動/槓桿調整813%\n終值 g = 2.5%',
source: 'MacroScope dcfValue();詳細數值見該次 DCF 卡片 ?',
}, },
cagr: { cagr: {
label: 'CAGR 年化報酬', label: 'CAGR 年化報酬',
@ -378,7 +336,7 @@ const TERM_TIPS = {
section_technical: { section_technical: {
label: '技術面', label: '技術面',
what: '用股價和成交量的圖表、指標,看短中期趨勢和熱度。', what: '用股價和成交量的圖表、指標,看短中期趨勢和熱度。',
how: '專業軟體通常主圖看價+均線,副圖一次開 12 個量、MACD、RSI、KDJ本頁可開關副圖避免畫面太擠。', how: '不能預測公司長期價值,但可幫你決定「現在進場會不會太追」。',
}, },
section_risk: { section_risk: {
label: '風險', label: '風險',
@ -397,9 +355,8 @@ const TERM_TIPS = {
}, },
section_forecast: { section_forecast: {
label: '預測', label: '預測',
what: '本區混合三類:① Yahoo 分析師共識 ② MacroScope 本機 DCF ③ 歷史 CAGR 推算。每一格旁邊 ? 會標公式與來源。', what: '分析師目標價、DCF 估算等「向前看」的數字,都是模型和猜測。',
how: '請以 ? 內「資料來源」「公式」核對;無法取得共識時會標示缺資料原因,不顯示假數字。', how: '當參考區間,不要當成精準預言。',
caveat: 'Yahoo 為非官方 API免費報價可能延遲。不構成投資建議。',
}, },
section_robust: { section_robust: {
label: '穩健度', label: '穩健度',
@ -418,35 +375,10 @@ function termTipHTML(t) {
let html = `<div class="tip-title">${_esc(t.label)}</div>`; let html = `<div class="tip-title">${_esc(t.label)}</div>`;
html += `<div class="tip-row"><span class="tip-k">白話說</span>${_esc(t.what)}</div>`; html += `<div class="tip-row"><span class="tip-k">白話說</span>${_esc(t.what)}</div>`;
if (t.how) html += `<div class="tip-row"><span class="tip-k">怎麼看</span>${_esc(t.how)}</div>`; if (t.how) html += `<div class="tip-row"><span class="tip-k">怎麼看</span>${_esc(t.how)}</div>`;
if (t.formula) html += `<div class="tip-row"><span class="tip-k">公式</span><pre class="tip-formula">${_esc(t.formula)}</pre></div>`;
if (t.model) html += `<div class="tip-row"><span class="tip-k">模型</span>${_esc(t.model)}</div>`;
if (t.source) html += `<div class="tip-row"><span class="tip-k">資料來源</span>${_esc(t.source)}</div>`;
if (t.example) html += `<div class="tip-row"><span class="tip-k">舉例</span>${_esc(t.example)}</div>`; if (t.example) html += `<div class="tip-row"><span class="tip-k">舉例</span>${_esc(t.example)}</div>`;
if (t.caveat) html += `<div class="tip-row tip-caveat"><span class="tip-k">注意</span>${_esc(t.caveat)}</div>`;
return html; return html;
} }
let _metricTipSeq = 0;
function resetMetricTips() {
window.__METRIC_TIPS = {};
_metricTipSeq = 0;
}
function registerMetricTip(detail) {
window.__METRIC_TIPS = window.__METRIC_TIPS || {};
const id = 'mt' + (++_metricTipSeq);
window.__METRIC_TIPS[id] = detail;
return id;
}
function metricTipBtn(id, label) {
if (!id || !window.__METRIC_TIPS?.[id]) return '';
const aria = label || window.__METRIC_TIPS[id].label || '說明';
return `<button type="button" class="info-btn" data-metric-tip="${_esc(id)}" aria-label="說明:${_esc(aria)}" tabindex="0">?</button>`;
}
function metricTipContent(id) {
const t = window.__METRIC_TIPS?.[id];
return t ? termTipHTML(t) : '';
}
function termTipContent(key) { function termTipContent(key) {
const t = TERM_TIPS[key]; const t = TERM_TIPS[key];
return t ? termTipHTML(t) : ''; return t ? termTipHTML(t) : '';
@ -455,9 +387,7 @@ function termTipContent(key) {
function showTermTip(btn) { function showTermTip(btn) {
const el = document.getElementById('tooltip'); const el = document.getElementById('tooltip');
if (!el) return; if (!el) return;
const html = btn.dataset.metricTip const html = termTipContent(btn.dataset.termKey);
? metricTipContent(btn.dataset.metricTip)
: termTipContent(btn.dataset.termKey);
if (!html) return; if (!html) return;
el.innerHTML = html; el.innerHTML = html;
el.classList.add('show'); el.classList.add('show');
@ -465,16 +395,10 @@ function showTermTip(btn) {
const tw = el.offsetWidth, th = el.offsetHeight; const tw = el.offsetWidth, th = el.offsetHeight;
let left = r.left + r.width / 2 - tw / 2; let left = r.left + r.width / 2 - tw / 2;
left = Math.max(10, Math.min(left, window.innerWidth - tw - 10)); left = Math.max(10, Math.min(left, window.innerWidth - tw - 10));
let top = r.top - th - 12; let top = r.top - th - 10;
if (top < 10) top = r.bottom + 10; if (top < 10) top = r.bottom + 10;
el.style.left = left + 'px'; el.style.left = left + 'px';
el.style.top = top + 'px'; el.style.top = top + 'px';
el.style.transform = '';
if (left <= 12) {
el.style.left = (r.right + 8) + 'px';
el.style.top = Math.max(10, r.top + r.height / 2 - th / 2) + 'px';
el.style.transform = 'none';
}
} }
function hideTermTip() { function hideTermTip() {
@ -484,7 +408,7 @@ function hideTermTip() {
function bindTermTips(root) { function bindTermTips(root) {
root = root || document; root = root || document;
root.querySelectorAll('.info-btn[data-term-key], .info-btn[data-metric-tip]').forEach(btn => { root.querySelectorAll('.info-btn[data-term-key]').forEach(btn => {
if (btn.dataset.termBound) return; if (btn.dataset.termBound) return;
btn.dataset.termBound = '1'; btn.dataset.termBound = '1';
btn.addEventListener('mouseenter', () => showTermTip(btn)); btn.addEventListener('mouseenter', () => showTermTip(btn));

View File

@ -50,13 +50,8 @@ async function fetchNasdaq(symbol, range, fromISO) {
const chart = j?.data?.chart; const chart = j?.data?.chart;
if (!Array.isArray(chart) || chart.length < 2) continue; if (!Array.isArray(chart) || chart.length < 2) continue;
const points = chart const points = chart
.map(c => { .map(c => ({ date: new Date(c.x).toISOString().slice(0, 10), close: c.y, adjclose: c.y }))
const y = c.y; .filter(p => p.close != null);
if (y == null) return null;
const date = new Date(c.x).toISOString().slice(0, 10);
return { date, open: y, high: y, low: y, close: y, adjclose: y, volume: c.volume ?? null };
})
.filter(Boolean);
if (points.length >= 2) { if (points.length >= 2) {
return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' }; return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' };
} }
@ -68,27 +63,14 @@ function normalizeYahooChart(d, symbol, range, interval) {
const r = d?.chart?.result?.[0]; const r = d?.chart?.result?.[0];
if (!r || !Array.isArray(r.timestamp)) throw new Error('Yahoo 無歷史資料'); if (!r || !Array.isArray(r.timestamp)) throw new Error('Yahoo 無歷史資料');
const ts = r.timestamp; const ts = r.timestamp;
const q = r.indicators?.quote?.[0] || {}; const close = r.indicators?.quote?.[0]?.close || [];
const close = q.close || [];
const open = q.open || [];
const high = q.high || [];
const low = q.low || [];
const volume = q.volume || [];
const adj = r.indicators?.adjclose?.[0]?.adjclose || []; const adj = r.indicators?.adjclose?.[0]?.adjclose || [];
const points = []; const points = [];
for (let i = 0; i < ts.length; i++) { for (let i = 0; i < ts.length; i++) {
const c = close[i]; const c = close[i];
if (c == null) continue; if (c == null) continue; // 跳過缺值(停牌/未成交)
const a = (adj[i] != null) ? adj[i] : c; const a = (adj[i] != null) ? adj[i] : c;
points.push({ points.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), close: c, adjclose: a });
date: new Date(ts[i] * 1000).toISOString().slice(0, 10),
open: open[i] != null ? open[i] : c,
high: high[i] != null ? high[i] : c,
low: low[i] != null ? low[i] : c,
close: c,
volume: volume[i] != null ? volume[i] : null,
adjclose: a,
});
} }
if (points.length < 1) throw new Error('歷史資料點過少'); if (points.length < 1) throw new Error('歷史資料點過少');
return { return {
@ -120,16 +102,14 @@ async function fetchYahooHistory(symbol, range, interval, fromISO) {
// 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] } // 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] }
export async function getHistory(symbol, range = '5y', interval = '1d') { export async function getHistory(symbol, range = '5y', interval = '1d') {
if (!RANGES.includes(range)) range = interval === '1d' ? 'max' : 'max'; if (!RANGES.includes(range)) range = '5y';
if (!INTERVALS.includes(interval)) interval = '1d'; if (!INTERVALS.includes(interval)) interval = '1d';
try { try {
const hist = await fetchYahooHistory(symbol, range, interval, null); const hist = await fetchYahooHistory(symbol, range, interval, null);
if (hist.points.length >= 2) return hist; if (hist.points.length >= 2) return hist;
} catch (e) { } catch (e) {
if (interval === '1d') { const fallback = await fetchNasdaq(symbol, range).catch(() => null);
const fallback = await fetchNasdaq(symbol, range).catch(() => null); if (fallback) return fallback;
if (fallback) return fallback;
}
throw e; throw e;
} }
throw new Error('歷史資料點過少'); throw new Error('歷史資料點過少');
@ -139,16 +119,12 @@ export async function getHistorySince(symbol, fromISO, range = 'max', interval =
if (!INTERVALS.includes(interval)) interval = '1d'; if (!INTERVALS.includes(interval)) interval = '1d';
const start = new Date(fromISO); const start = new Date(fromISO);
if (isNaN(start)) throw new Error('起始日期不正確'); if (isNaN(start)) throw new Error('起始日期不正確');
const padDays = interval === '1mo' ? 45 : interval === '1wk' ? 14 : 5; const since = new Date(start.getTime() - 3 * 86400000).toISOString().slice(0, 10);
const since = new Date(start.getTime() - padDays * 86400000).toISOString().slice(0, 10);
try { try {
return await fetchYahooHistory(symbol, range, interval, since); return await fetchYahooHistory(symbol, range, interval, since);
} catch (e) { } catch {
if (interval === '1d') { const fallback = await fetchNasdaq(symbol, range, since).catch(() => null);
const fallback = await fetchNasdaq(symbol, range, since).catch(() => null); if (fallback) return fallback;
if (fallback) return fallback;
}
throw e;
} }
throw new Error('無法取得增量歷史股價'); throw new Error('無法取得增量歷史股價');
} }

View File

@ -1,99 +0,0 @@
// Google RSS / 新聞欄位HTML 實體解碼與摘要清理
export function decodeHtmlEntities(s) {
let t = String(s ?? '');
if (!t) return '';
t = t.replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
const cp = parseInt(hex, 16);
return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : '';
});
t = t.replace(/&#(\d+);/g, (_, dec) => {
const cp = Number(dec);
return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : '';
});
const map = {
'&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&#39;': "'", '&apos;': "'",
'&nbsp;': ' ', '&#160;': ' ',
};
for (const [ent, ch] of Object.entries(map)) {
if (t.includes(ent)) t = t.split(ent).join(ch);
}
return t;
}
/** 解碼後移除標籤、壓縮空白 */
export function cleanNewsPlain(s) {
const decoded = decodeHtmlEntities(s);
return decoded
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function looksLikeHtmlGarbage(s) {
const t = String(s || '');
return /&lt;|&gt;|&amp;#|href\s*=|target\s*=\s*["']?_blank/i.test(t)
|| /^https?:\/\//i.test(t)
|| t.length > 120 && /news\.google\.com/i.test(t);
}
/** 從 Google RSS description 抽出摘要與媒體提示 */
export function parseGoogleRssDescription(raw) {
const decoded = decodeHtmlEntities(raw);
const anchorText = cleanNewsPlain(decoded.match(/<a[^>]*>([\s\S]*?)<\/a>/i)?.[1] || '');
const fontPub = cleanNewsPlain(decoded.match(/<font[^>]*>([\s\S]*?)<\/font>/i)?.[1] || '');
return { anchorText, fontPub };
}
export function cleanGoogleNewsTitle(raw) {
let title = cleanNewsPlain(raw);
// 「標題 - 媒體名」尾綴
title = title.replace(/\s*[-–—|]\s*[^-–—|]{1,48}$/, '').trim();
return title;
}
export function normalizeNewsItem(item = {}) {
const rawTitle = item.titleZh || item.title || '';
const titleZh = cleanGoogleNewsTitle(rawTitle) || cleanNewsPlain(rawTitle) || '(無標題)';
const titleEn = item.title && item.title !== rawTitle
? cleanGoogleNewsTitle(item.title)
: (item.title && item.title !== titleZh ? cleanGoogleNewsTitle(item.title) : '');
const rawPublisher = item.publisher || '';
let publisher = '';
if (looksLikeHtmlGarbage(rawPublisher)) {
const { fontPub } = parseGoogleRssDescription(rawPublisher);
publisher = fontPub || '';
} else {
publisher = cleanNewsPlain(rawPublisher);
}
if (!publisher || looksLikeHtmlGarbage(publisher)) {
const fromSource = cleanNewsPlain(item.source || '');
publisher = fromSource && !looksLikeHtmlGarbage(fromSource) && !/Google\s*新聞/i.test(fromSource)
? fromSource
: '';
}
if (!publisher || looksLikeHtmlGarbage(publisher)) publisher = '新聞';
let description = cleanNewsPlain(item.descriptionZh || item.description || '');
if (looksLikeHtmlGarbage(description)) description = '';
if (description && (description === titleZh || description === rawTitle || titleZh.includes(description))) {
description = '';
}
if (description && /news\.google\.com\/rss\/articles/i.test(description)) description = '';
return {
...item,
title: titleEn || titleZh,
titleZh,
description: description.slice(0, 400),
descriptionZh: description.slice(0, 400),
publisher,
};
}
export function normalizeNewsList(list) {
return (list || []).map(normalizeNewsItem);
}

View File

@ -1,315 +0,0 @@
// 個股 OHLCV以 SQLite price_bars 為準Yahoo/Nasdaq 只補「還沒有的」K 線
import {
upsertPriceBars, getPriceBars, getPriceBarMeta, priceBarsToPoints, deletePriceBars,
getCachedEntry, putCachedJSON,
} from './db.js';
import { getHistory, getHistorySince } from './marketdata.js';
const META_PREFIX = 'histmeta:';
function metaKey(symbol, interval) {
return `${META_PREFIX}${symbol}:${interval}`;
}
function barPointCount(bars) {
return bars?.length || 0;
}
/** 從舊版 JSON 快取hist:SYM:range:1d匯入 DB避免重打 API */
export function importLegacyHistCaches(symbol, interval = '1d') {
const keys = [
`hist:${symbol}:max:${interval}`,
`hist:${symbol}:10y:${interval}`,
`hist:${symbol}:5y:${interval}`,
`hist:${symbol}:2y:${interval}`,
];
let best = null;
for (const key of keys) {
const entry = getCachedEntry(key);
const pts = entry?.value?.points;
if (!pts?.length) continue;
if (!best || pts.length > best.points.length) best = { key, entry, points: pts };
}
if (!best) return 0;
const n = upsertPriceBars(symbol, interval, best.points);
const v = best.entry.value;
putCachedJSON(metaKey(symbol, interval), {
symbol: v.symbol || symbol,
name: v.name || null,
currency: v.currency || null,
source: v.source || 'legacy-cache',
interval,
_importedFrom: best.key,
_fetchedAt: best.entry.updatedAt || Date.now(),
});
return n;
}
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
function startOfWeekISO(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
const diff = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
return d.toISOString().slice(0, 10);
}
/** 非即時研究:去掉可能尚未收盤的當根 K日線≤昨日、周線≤上週、月線≤上月 */
export function filterResearchBars(points, interval = '1d') {
if (!points?.length) return [];
const today = todayISO();
if (interval === '1d') {
return points.filter(p => p.date < today);
}
const last = points[points.length - 1];
if (interval === '1wk') {
if (startOfWeekISO(last.date) >= startOfWeekISO(today)) return points.slice(0, -1);
return points;
}
if (interval === '1mo') {
if (last.date.slice(0, 7) >= today.slice(0, 7)) return points.slice(0, -1);
return points;
}
return points;
}
const TTL_BY_INTERVAL = {
'1d': 6 * 3600 * 1000,
'1wk': 24 * 3600 * 1000,
'1mo': 7 * 24 * 3600 * 1000,
};
function weekKey(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z');
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - day);
const y = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const w = Math.ceil((((d - y) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(w).padStart(2, '0')}`;
}
function resampleBars(dailyPoints, interval) {
const keyFn = interval === '1wk'
? weekKey
: (dateStr) => dateStr.slice(0, 7);
const groups = new Map();
for (const p of dailyPoints) {
const k = keyFn(p.date);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(p);
}
const out = [];
for (const arr of [...groups.values()]) {
arr.sort((a, b) => (a.date < b.date ? -1 : 1));
const last = arr[arr.length - 1];
const highs = arr.map(x => x.high ?? x.close);
const lows = arr.map(x => x.low ?? x.close);
out.push({
date: last.date,
open: arr[0].open ?? arr[0].close,
high: Math.max(...highs),
low: Math.min(...lows),
close: last.close,
volume: arr.some(x => x.volume != null) ? arr.reduce((s, x) => s + (x.volume || 0), 0) : null,
adjclose: last.adjclose ?? last.close,
});
}
return out.sort((a, b) => (a.date < b.date ? -1 : 1));
}
async function fetchOrResample(symbol, interval) {
if (interval === '1d') return getHistory(symbol, 'max', '1d');
try {
const hist = await getHistory(symbol, 'max', interval);
if (hist.points?.length >= 2 && barsMatchInterval(
hist.points.map(p => ({ date: p.date })),
interval,
)) return hist;
} catch (_) { /* Yahoo 429 等 → 改本機重採樣 */ }
await ensurePriceHistory(symbol, '1d', { fresh: false });
const daily = priceBarsToPoints(getPriceBars(symbol, '1d'));
if (daily.length < 40) throw new Error('日線不足,無法合成周/月線');
const points = resampleBars(daily, interval);
if (points.length < 2) throw new Error('重採樣後資料過少');
return {
symbol,
name: null,
currency: null,
range: 'max',
interval,
source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線',
points,
};
}
/** 偵測 DB 是否誤存日線到周/月線 */
function barsMatchInterval(bars, interval) {
if (!bars?.length || interval === '1d') return true;
if (interval === '1wk' && bars.length > 600) return false;
if (interval === '1mo' && bars.length > 200) return false;
if (bars.length < 3) return true;
const i = bars.length - 1;
const gap = (new Date(bars[i].date) - new Date(bars[i - 1].date)) / 86400000;
if (interval === '1wk') return gap >= 4;
if (interval === '1mo') return gap >= 20;
return true;
}
function planFetch(bars, metaUpdatedAt, fresh, ttlMs, interval) {
if (!bars.length) return 'full';
if (fresh) return 'incremental';
const research = filterResearchBars(bars.map(b => ({ date: b.date })), interval);
const lastComplete = research.length ? research[research.length - 1].date : null;
const lastStored = bars[bars.length - 1].date;
const age = Date.now() - (metaUpdatedAt || 0);
if (lastComplete && lastStored > lastComplete && age > ttlMs / 2) return 'incremental';
if (lastComplete && lastComplete < todayISO() && age > ttlMs) return 'incremental';
if (age > ttlMs * 14) return 'incremental';
return null;
}
/**
* @returns {{ payload: object, cached: boolean, fetchMode: string|null }}
*/
export async function ensurePriceHistory(symbol, interval = '1d', { fresh = false, ttlMs } = {}) {
if (!ttlMs) ttlMs = TTL_BY_INTERVAL[interval] || TTL_BY_INTERVAL['1d'];
let bars = getPriceBars(symbol, interval);
if (!bars.length) importLegacyHistCaches(symbol, interval);
bars = getPriceBars(symbol, interval);
if (bars.length && !barsMatchInterval(bars, interval)) {
deletePriceBars(symbol, interval);
bars = [];
}
const mk = metaKey(symbol, interval);
let metaEntry = getCachedEntry(mk);
let meta = metaEntry?.value || { symbol, interval };
const mode = planFetch(bars, metaEntry?.updatedAt, fresh, ttlMs, interval);
let fetchError = null;
if (mode === 'full') {
try {
const hist = await fetchOrResample(symbol, interval);
upsertPriceBars(symbol, interval, hist.points);
meta = {
symbol: hist.symbol || symbol,
name: hist.name,
currency: hist.currency,
source: hist.source,
interval,
_fetchedAt: Date.now(),
_fetchMode: 'full',
};
putCachedJSON(mk, meta);
bars = getPriceBars(symbol, interval);
} catch (e) {
fetchError = String(e?.message || e);
if (!bars.length) throw e;
}
} else if (mode === 'incremental') {
const lastDate = bars[bars.length - 1].date;
try {
let patch;
try {
patch = await getHistorySince(symbol, lastDate, 'max', interval);
if (interval !== '1d' && !barsMatchInterval(patch.points.map(p => ({ date: p.date })), interval)) {
throw new Error('patch_not_weekly');
}
} catch {
const daily = priceBarsToPoints(getPriceBars(symbol, '1d'));
const resampled = resampleBars(daily, interval);
const idx = resampled.findIndex(p => p.date > lastDate);
patch = {
symbol,
interval,
points: idx >= 0 ? resampled.slice(idx) : [],
source: interval === '1wk' ? '本機日線→周線' : '本機日線→月線',
};
}
const added = upsertPriceBars(symbol, interval, patch.points);
meta = {
...meta,
symbol: patch.symbol || symbol,
name: patch.name || meta.name,
currency: patch.currency || meta.currency,
source: patch.source || meta.source,
interval,
_fetchedAt: Date.now(),
_fetchMode: 'incremental',
_lastIncrementalAt: Date.now(),
_barsAdded: added,
};
putCachedJSON(mk, meta);
bars = getPriceBars(symbol, interval);
} catch (e) {
fetchError = String(e?.message || e);
}
}
const stat = getPriceBarMeta(symbol, interval);
const allPoints = priceBarsToPoints(bars);
const points = filterResearchBars(allPoints, interval);
const lastResearch = points.length ? points[points.length - 1].date : null;
return {
payload: {
symbol,
name: meta.name || null,
currency: meta.currency || null,
interval,
source: meta.source || 'MacroScope DB',
range: 'max',
points,
allBarsPoints: allPoints,
dbBars: stat.n,
researchBars: points.length,
firstDate: points[0]?.date || stat.first_date,
lastDate: lastResearch || stat.last_date,
researchThrough: lastResearch,
researchNote: interval === '1d'
? '研究用日線截至昨日完整 K 線(今日未收盤不納入)'
: interval === '1wk'
? '研究用周線截至上一根完整週 K'
: '研究用月線截至上一根完整月 K',
cached: mode == null,
fetchMode: mode,
fetchError,
},
cached: mode == null,
fetchMode: mode,
};
}
/** 成交量圖:在研究用 K 線之外,盡量附上「當日」成交量(來自 DB 當根或即時報價) */
export function buildVolumeSeries(researchPoints, allPoints, quote = {}, interval = '1d') {
if (interval !== '1d' || !researchPoints?.length) return researchPoints || [];
const today = todayISO();
let todayVol = quote?.volume != null ? Number(quote.volume) : null;
const rawToday = (allPoints || []).find(p => p.date === today);
if (rawToday?.volume != null) todayVol = Number(rawToday.volume);
const pts = researchPoints.map(p => ({ ...p }));
if (todayVol == null || isNaN(todayVol)) return pts;
const last = pts[pts.length - 1];
if (last?.date === today) {
pts[pts.length - 1] = { ...last, volume: todayVol };
return pts;
}
const px = quote?.price ?? quote?.regularMarketPrice ?? last?.close;
if (px == null) return pts;
pts.push({
date: today,
open: quote?.previousClose ?? px,
high: quote?.dayHigh ?? px,
low: quote?.dayLow ?? px,
close: px,
volume: todayVol,
adjclose: px,
partialSession: true,
});
return pts;
}

View File

@ -1,433 +0,0 @@
// SEC 重要申報與財報/法說相關資料:抓取後寫入本機 archive/ + SQLite避免連結失效
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
listSecFilings, upsertSecFiling, listEarningsEvents, upsertEarningsEvent,
getSecArchiveMeta, saveSecArchiveMeta,
} from './db.js';
import { fetchEarningsEvents } from './calendar.js';
import { resolveInvestorRelationsUrl } from './companyintel-links.js';
import { yahooQuoteSummary } from './yahoo-session.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ARCHIVE_ROOT = path.join(__dirname, '..', 'archive', 'sec');
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
const MAX_FILINGS_SYNC = 36;
const MAX_FILE_BYTES = 12 * 1024 * 1024;
const EXCERPT_LEN = 4000;
const IMPORTANT_FORMS = new Set([
'10-K', '10-Q', '8-K', '6-K', '20-F', 'DEF 14A', 'DEFA14A', 'S-1', 'S-3', 'F-1', 'F-3',
'424B1', '424B2', '424B3', '424B4', '424B5', 'SC 13D', 'SC 13G', '4', '3', '5',
]);
const FORM_ZH = {
'10-K': '年報', '10-Q': '季報', '8-K': '重大事件(含財報公告)', '6-K': '外國公司重大事件',
'20-F': '外國公司年報', 'DEF 14A': '股東會說明書', 'DEFA14A': '股東會補充', 'S-1': '上市/增資說明',
'S-3': '增資說明', 'F-1': '外國公司上市', 'F-3': '外國公司增資', '4': '內部人交易',
'3': '內部人持股', '5': '內部人年度', 'SC 13D': '主動持股申報', 'SC 13G': '被動持股申報',
};
function formLabelZh(form) {
const base = String(form || '').replace(/\/A$/i, '');
return FORM_ZH[base] || FORM_ZH[form] || form;
}
function isImportantForm(form) {
const f = String(form || '').trim();
if (!f) return false;
const base = f.replace(/\/A$/i, '');
if (IMPORTANT_FORMS.has(base) || IMPORTANT_FORMS.has(f)) return true;
if (/^424B/i.test(f)) return true;
return false;
}
let _tickerMap = null;
async function tickerToCik(symbol) {
if (!_tickerMap) {
const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: { 'User-Agent': SEC_UA } });
if (!res.ok) throw new Error(`SEC tickers HTTP ${res.status}`);
const d = await res.json();
_tickerMap = {};
for (const k of Object.keys(d)) {
_tickerMap[String(d[k].ticker).toUpperCase()] = {
cik: String(d[k].cik_str).padStart(10, '0'),
name: d[k].title,
};
}
}
return _tickerMap[symbol] || null;
}
async function text(url, ms = 20000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': SEC_UA }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally { clearTimeout(timer); }
}
async function json(url, ms = 15000) {
const res = await fetch(url, { headers: { 'User-Agent': SEC_UA, Accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function accNoDash(accn) {
return String(accn || '').replace(/-/g, '');
}
function filingDir(symbol, accn) {
return path.join(ARCHIVE_ROOT, symbol, accNoDash(accn));
}
function edgarPrimaryUrl(cikNum, accn, primary) {
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${primary}`;
}
function edgarTxtUrl(cikNum, accn) {
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${accn}.txt`;
}
function edgarIndexJsonUrl(cikNum, accn) {
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNoDash(accn)}/${accn}-index.json`;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeIfAbsent(filePath, content) {
if (fs.existsSync(filePath)) return false;
fs.writeFileSync(filePath, content);
return true;
}
function excerptFromHtml(html) {
const plain = String(html || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&#\d+;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return plain.slice(0, EXCERPT_LEN);
}
function isEarnings8k(txt, description) {
const blob = `${description || ''}\n${txt || ''}`.slice(0, 120000);
return /Item\s+2\.02/i.test(blob) || /Results of Operations and Financial Condition/i.test(blob)
|| /財報|earnings release|quarterly results/i.test(blob);
}
function collectFilingsFromSubmissions(sub, symbol) {
const f = sub.filings?.recent || {};
const out = [];
const forms = f.form || [];
for (let i = 0; i < forms.length && out.length < MAX_FILINGS_SYNC * 2; i++) {
const form = forms[i];
if (!isImportantForm(form)) continue;
const accn = f.accessionNumber[i];
if (!accn) continue;
out.push({
symbol,
accession: accn,
form,
formZh: formLabelZh(form),
filedDate: f.filingDate[i] || null,
reportDate: f.reportDate?.[i] || null,
primaryDocument: f.primaryDocument?.[i] || null,
description: f.primaryDocDescription?.[i] || f.description?.[i] || '',
isEarningsRelated: form.replace(/\/A$/i, '') === '8-K',
});
if (out.length >= MAX_FILINGS_SYNC) break;
}
return out;
}
async function downloadToFile(url, destPath) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 45000);
try {
const res = await fetch(url, { headers: { 'User-Agent': SEC_UA }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > MAX_FILE_BYTES) return { skipped: true, reason: 'too_large', size: buf.length };
ensureDir(path.dirname(destPath));
fs.writeFileSync(destPath, buf);
return { skipped: false, size: buf.length };
} finally { clearTimeout(timer); }
}
async function archiveFiling(meta, cikNum) {
const { symbol, accession, primaryDocument } = meta;
const dir = filingDir(symbol, accession);
const metaPath = path.join(dir, 'meta.json');
if (fs.existsSync(metaPath)) {
try {
const prev = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
if (prev.localPrimary) {
return { ...meta, localPrimary: prev.localPrimary, localTxt: prev.localTxt || null, excerpt: prev.excerpt || null, archived: true, reused: true };
}
} catch { /* re-download */ }
}
ensureDir(dir);
const files = { localPrimary: null, localTxt: null, excerpt: null, archived: false, reused: false };
const primary = primaryDocument || `${accession}.txt`;
if (primary && !primary.endsWith('.txt')) {
const url = edgarPrimaryUrl(cikNum, accession, primary);
const ext = path.extname(primary) || '.htm';
const dest = path.join(dir, `primary${ext}`);
try {
const r = await downloadToFile(url, dest);
if (!r.skipped) {
files.localPrimary = path.relative(path.join(__dirname, '..'), dest);
const html = fs.readFileSync(dest, 'utf8');
files.excerpt = excerptFromHtml(html);
files.archived = true;
}
} catch { /* metadata only */ }
}
const txtUrl = edgarTxtUrl(cikNum, accession);
const txtDest = path.join(dir, 'filing.txt');
try {
const r = await downloadToFile(txtUrl, txtDest);
if (!r.skipped) {
files.localTxt = path.relative(path.join(__dirname, '..'), txtDest);
files.archived = true;
if (!files.excerpt) {
const raw = fs.readFileSync(txtDest, 'utf8').slice(0, 80000);
files.excerpt = excerptFromHtml(raw);
}
}
} catch { /* ok */ }
const earningsExhibits = [];
if (meta.isEarningsRelated) {
try {
const idx = await json(edgarIndexJsonUrl(cikNum, accession));
const items = idx.directory?.item || [];
for (const it of items) {
const name = String(it.name || '');
const desc = String(it.description || '');
if (!/ex-99|press release|earnings/i.test(name + desc)) continue;
if (!/\.htm|\.html|\.txt$/i.test(name)) continue;
const exUrl = edgarPrimaryUrl(cikNum, accession, name);
const exDest = path.join(dir, name.replace(/[^\w.\-]+/g, '_'));
try {
const r = await downloadToFile(exUrl, exDest);
if (!r.skipped) {
earningsExhibits.push({
name,
description: desc,
localPath: path.relative(path.join(__dirname, '..'), exDest),
url: exUrl,
});
}
} catch { /* skip exhibit */ }
}
} catch { /* no index */ }
}
const fullMeta = {
...meta,
...files,
earningsExhibits,
edgarUrl: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${cikNum}&type=${encodeURIComponent(meta.form)}&dateb=&owner=include&count=40`,
archivedAt: new Date().toISOString(),
};
fs.writeFileSync(metaPath, JSON.stringify(fullMeta, null, 2));
return fullMeta;
}
async function syncEarningsCalendar(symbol) {
const today = new Date();
const start = new Date(today);
start.setUTCDate(start.getUTCDate() - 400);
const end = new Date(today);
end.setUTCDate(end.getUTCDate() + 120);
const startISO = start.toISOString().slice(0, 10);
const endISO = end.toISOString().slice(0, 10);
const events = await fetchEarningsEvents(startISO, endISO, [symbol]);
let n = 0;
for (const ev of events) {
upsertEarningsEvent({
symbol,
eventDate: ev.date,
title: ev.title,
titleZh: ev.title,
timeLabel: ev.time || '',
source: ev.source || 'Nasdaq earnings',
url: ev.url,
note: ev.note || '',
kind: 'earnings_calendar',
});
n++;
}
return n;
}
export async function syncSecArchive(symbol, { force = false } = {}) {
symbol = String(symbol || '').trim().toUpperCase();
if (!symbol) throw new Error('bad_symbol');
const hit = await tickerToCik(symbol);
if (!hit) throw new Error('cik_not_found');
const meta0 = getSecArchiveMeta(symbol);
const softMs = (Number(process.env.SEC_ARCHIVE_SOFT_HOURS) || 12) * 3600 * 1000;
if (!force && meta0?.lastSyncAt && Date.now() - meta0.lastSyncAt < softMs) {
return {
symbol,
skipped: true,
filings: listSecFilings(symbol),
earnings: listEarningsEvents(symbol),
meta: meta0,
};
}
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`);
const cikNum = Number(hit.cik);
let investorUrl = null;
try {
const y = await yahooQuoteSummary(symbol, 'assetProfile');
investorUrl = resolveInvestorRelationsUrl(y?.assetProfile?.website)?.url || null;
} catch { /* */ }
const candidates = collectFilingsFromSubmissions(sub, symbol);
const synced = [];
let downloaded = 0;
for (const row of candidates) {
let archived = null;
try {
archived = await archiveFiling({ ...row, cik: hit.cik, companyName: hit.name }, cikNum);
if (archived.archived && !archived.reused) downloaded++;
} catch {
archived = { ...row, archived: false };
}
let earningsFlag = row.isEarningsRelated;
let excerpt = archived?.excerpt || null;
if (earningsFlag && archived?.localTxt) {
try {
const txt = fs.readFileSync(path.join(__dirname, '..', archived.localTxt), 'utf8');
earningsFlag = isEarnings8k(txt, row.description);
if (earningsFlag && !excerpt) excerpt = excerptFromHtml(txt);
} catch { /* */ }
} else if (archived?.localTxt) {
try {
const txt = fs.readFileSync(path.join(__dirname, '..', archived.localTxt), 'utf8').slice(0, 50000);
if (isEarnings8k(txt, row.description)) earningsFlag = true;
} catch { /* */ }
}
upsertSecFiling({
symbol,
accession: row.accession,
form: row.form,
formZh: row.formZh,
filedDate: row.filedDate,
reportDate: row.reportDate,
description: row.description,
primaryDocument: row.primaryDocument,
url: row.primaryDocument
? edgarPrimaryUrl(cikNum, row.accession, row.primaryDocument)
: edgarTxtUrl(cikNum, row.accession),
localPrimary: archived?.localPrimary || null,
localTxt: archived?.localTxt || null,
excerpt,
isEarningsRelated: earningsFlag ? 1 : 0,
earningsExhibits: archived?.earningsExhibits ? JSON.stringify(archived.earningsExhibits) : null,
});
if (earningsFlag) {
upsertEarningsEvent({
symbol,
eventDate: row.reportDate || row.filedDate,
title: `${symbol} 財報/重大事件 8-K`,
titleZh: `${symbol} 財報公告8-K Item 2.02`,
timeLabel: '',
source: 'SEC 8-K',
url: archived?.localPrimary
? null
: (row.primaryDocument ? edgarPrimaryUrl(cikNum, row.accession, row.primaryDocument) : edgarTxtUrl(cikNum, row.accession)),
note: row.description || '已封存申報全文;法說逐字稿多由公司投資人關係頁發布',
kind: 'sec_8k',
accession: row.accession,
transcriptSearchUrl: investorUrl,
});
}
synced.push({
...row,
archived: !!archived?.archived,
localPrimary: archived?.localPrimary,
isEarningsRelated: earningsFlag,
});
}
const earnN = await syncEarningsCalendar(symbol).catch(() => 0);
const meta = {
symbol,
companyName: hit.name,
cik: hit.cik,
lastSyncAt: Date.now(),
filingCount: listSecFilings(symbol).length,
earningsCount: listEarningsEvents(symbol).length,
downloadedThisRun: downloaded,
earningsCalendarSynced: earnN,
};
saveSecArchiveMeta(symbol, meta);
return {
symbol,
skipped: false,
filings: listSecFilings(symbol),
earnings: listEarningsEvents(symbol),
meta,
synced,
};
}
export function getSecArchivePayload(symbol) {
symbol = String(symbol || '').trim().toUpperCase();
return {
symbol,
filings: listSecFilings(symbol),
earnings: listEarningsEvents(symbol),
meta: getSecArchiveMeta(symbol),
};
}
export function resolveArchiveFile(symbol, accession, file) {
symbol = String(symbol || '').trim().toUpperCase();
const dir = filingDir(symbol, accession);
if (!fs.existsSync(dir)) return null;
const safe = path.basename(String(file || 'primary.htm'));
const full = path.join(dir, safe);
if (!full.startsWith(dir)) return null;
if (!fs.existsSync(full)) {
const metaPath = path.join(dir, 'meta.json');
if (fs.existsSync(metaPath)) {
try {
const m = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
if (m.localPrimary) {
const p = path.join(__dirname, '..', m.localPrimary);
if (fs.existsSync(p)) return p;
}
if (m.localTxt) {
const p = path.join(__dirname, '..', m.localTxt);
if (fs.existsSync(p)) return p;
}
} catch { /* */ }
}
return null;
}
return full;
}

View File

@ -1,372 +0,0 @@
// 美股 11 大板塊SPDR 行業 ETF— 熱力圖、輪動、資金流向、ETF 規模(機構被動配置 proxy
import { getHistory } from './marketdata.js';
import { yahooQuoteSummary, resetYahooAuth, sleep as yahooSleep } from './yahoo-session.js';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
export const SECTOR_ETFS = [
{ etf: 'XLK', nameZh: '科技', nameEn: 'Technology', group: 'growth' },
{ etf: 'XLC', nameZh: '通訊服務', nameEn: 'Communication', group: 'growth' },
{ etf: 'XLY', nameZh: '非必需消費', nameEn: 'Cons. Discretionary', group: 'cyclical' },
{ etf: 'XLP', nameZh: '必需消費', nameEn: 'Cons. Staples', group: 'defensive' },
{ etf: 'XLE', nameZh: '能源', nameEn: 'Energy', group: 'cyclical' },
{ etf: 'XLF', nameZh: '金融', nameEn: 'Financials', group: 'cyclical' },
{ etf: 'XLV', nameZh: '醫療保健', nameEn: 'Health Care', group: 'defensive' },
{ etf: 'XLI', nameZh: '工業', nameEn: 'Industrials', group: 'cyclical' },
{ etf: 'XLB', nameZh: '原物料', nameEn: 'Materials', group: 'cyclical' },
{ etf: 'XLRE', nameZh: '房地產', nameEn: 'Real Estate', group: 'rate_sensitive' },
{ etf: 'XLU', nameZh: '公用事業', nameEn: 'Utilities', group: 'defensive' },
];
const BENCHMARK = 'SPY';
/** Yahoo 限流時的示意持股(僅 SPY板塊 ETF 仍嘗試即時抓取) */
const TOP_HOLDINGS_FALLBACK = {
SPY: [
{ symbol: 'NVDA', name: 'NVIDIA Corp', pct: 7.85, pctFmt: '7.85%' },
{ symbol: 'AAPL', name: 'Apple Inc', pct: 6.45, pctFmt: '6.45%' },
{ symbol: 'MSFT', name: 'Microsoft Corp', pct: 4.9, pctFmt: '4.90%' },
{ symbol: 'AMZN', name: 'Amazon.com Inc', pct: 3.8, pctFmt: '3.80%' },
{ symbol: 'META', name: 'Meta Platforms Inc', pct: 3.2, pctFmt: '3.20%' },
{ symbol: 'GOOGL', name: 'Alphabet Inc Class A', pct: 2.9, pctFmt: '2.90%' },
{ symbol: 'GOOG', name: 'Alphabet Inc Class C', pct: 2.5, pctFmt: '2.50%' },
{ symbol: 'BRK-B', name: 'Berkshire Hathaway Inc Class B', pct: 2.4, pctFmt: '2.40%' },
{ symbol: 'AVGO', name: 'Broadcom Inc', pct: 2.3, pctFmt: '2.30%' },
{ symbol: 'TSLA', name: 'Tesla Inc', pct: 2.1, pctFmt: '2.10%' },
],
};
function closeAt(points, idx) {
if (!points?.length) return null;
const i = idx < 0 ? points.length + idx : idx;
const p = points[i];
if (!p) return null;
return p.adjclose ?? p.close;
}
function returnOver(points, days) {
if (!points?.length || points.length <= days) return null;
const last = closeAt(points, -1);
const prev = closeAt(points, -1 - days);
if (last == null || prev == null || !prev) return null;
return ((last / prev) - 1) * 100;
}
function avgVolume(points, days) {
const slice = points.slice(-days).map(p => p.volume).filter(v => v != null && v > 0);
if (!slice.length) return null;
return slice.reduce((a, b) => a + b, 0) / slice.length;
}
async function fetchEtfTopHoldings(symbols, limit = 10) {
const out = {};
for (const sym of symbols) {
try {
const r = await yahooQuoteSummary(sym, 'topHoldings');
const list = r?.topHoldings?.holdings || [];
out[sym] = list.slice(0, limit).map(h => ({
symbol: (h.symbol || '').toUpperCase(),
name: h.holdingName || h.symbol,
pct: h.holdingPercent?.raw != null ? h.holdingPercent.raw * 100 : null,
pctFmt: h.holdingPercent?.fmt || null,
})).filter(h => h.symbol);
} catch { /* skip */ }
await new Promise(r => setTimeout(r, 120));
}
return out;
}
function mergeHoldingsWithFallback(fetched, symbols) {
const out = { ...fetched };
let usedFallback = false;
for (const sym of symbols) {
if (out[sym]?.length) continue;
if (TOP_HOLDINGS_FALLBACK[sym]) {
out[sym] = TOP_HOLDINGS_FALLBACK[sym];
usedFallback = true;
}
}
return { out, usedFallback };
}
async function fetchEtfAum(symbols) {
const out = {};
for (const sym of symbols) {
try {
const r = await yahooQuoteSummary(sym, 'defaultKeyStatistics');
const raw = r?.defaultKeyStatistics?.totalAssets?.raw;
if (raw != null) out[sym] = raw;
} catch { /* skip symbol */ }
await new Promise(r => setTimeout(r, 100));
}
return out;
}
function rotationQuadrant(rs20, momentum) {
// 類 RRGX=相對大盤強度Y=短期動能5日減20日
if (rs20 >= 0 && momentum >= 0) return { key: 'leading', labelZh: '領漲', tone: 'good' };
if (rs20 >= 0 && momentum < 0) return { key: 'weakening', labelZh: '轉弱', tone: 'warn' };
if (rs20 < 0 && momentum >= 0) return { key: 'improving', labelZh: '改善', tone: 'good' };
return { key: 'lagging', labelZh: '落後', tone: 'bad' };
}
function buildSectorRow(meta, points, spyPoints, aum) {
const ret1d = returnOver(points, 1);
const ret5d = returnOver(points, 5);
const ret20d = returnOver(points, 20);
const ret60d = returnOver(points, 60);
const spy20 = returnOver(spyPoints, 20);
const spy5 = returnOver(spyPoints, 5);
const rs20 = ret20d != null && spy20 != null ? ret20d - spy20 : null;
const rs5 = ret5d != null && spy5 != null ? ret5d - spy5 : null;
const momentum = rs5 != null && rs20 != null ? rs5 - rs20 : null;
const volToday = points.at(-1)?.volume;
const avgVol = avgVolume(points, 20);
const volRatio = volToday && avgVol ? volToday / avgVol : null;
const flowScore = volRatio != null && ret5d != null
? volRatio * (ret5d >= 0 ? 1 : -1) * Math.min(Math.abs(ret5d), 8)
: null;
const quad = rs20 != null && momentum != null ? rotationQuadrant(rs20, momentum) : null;
const price = closeAt(points, -1);
return {
...meta,
price,
ret1d, ret5d, ret20d, ret60d,
rs20, rs5, momentum,
volRatio,
flowScore,
aum,
aumB: aum != null ? aum / 1e9 : null,
quadrant: quad,
};
}
function summarizeRotation(rows) {
const ranked = [...rows].filter(r => r.rs20 != null).sort((a, b) => b.rs20 - a.rs20);
const leader = ranked[0];
const laggard = ranked[ranked.length - 1];
const byQuad = { leading: [], weakening: [], improving: [], lagging: [] };
for (const r of rows) {
if (r.quadrant?.key) byQuad[r.quadrant.key].push(r.etf);
}
const cyclicalAvg = avgOf(rows.filter(r => r.group === 'cyclical' || r.group === 'growth'), 'rs20');
const defensiveAvg = avgOf(rows.filter(r => r.group === 'defensive' || r.group === 'rate_sensitive'), 'rs20');
let regime = '均衡輪動';
let regimeNote = '景氣敏感與防禦板塊表現接近,資金未明顯單邊押注。';
if (cyclicalAvg != null && defensiveAvg != null) {
const spread = cyclicalAvg - defensiveAvg;
if (spread > 1.5) {
regime = '偏景氣/成長';
regimeNote = `循環型板塊 20 日相對強度平均較防禦型高 ${spread.toFixed(1)} 個百分點,資金偏向風險與景氣復甦敘事。`;
} else if (spread < -1.5) {
regime = '偏防禦';
regimeNote = `防禦型板塊相對較強(差距約 ${Math.abs(spread).toFixed(1)} pct市場偏避險或降風險偏好。`;
}
}
return {
leader: leader ? { etf: leader.etf, nameZh: leader.nameZh, rs20: leader.rs20 } : null,
laggard: laggard ? { etf: laggard.etf, nameZh: laggard.nameZh, rs20: laggard.rs20 } : null,
ranked: ranked.map(r => ({ etf: r.etf, nameZh: r.nameZh, rs20: r.rs20, quadrant: r.quadrant?.labelZh })),
byQuadrant: byQuad,
regime,
regimeNote,
cyclicalAvg,
defensiveAvg,
};
}
function avgOf(rows, field) {
const vals = rows.map(r => r[field]).filter(v => v != null);
if (!vals.length) return null;
return vals.reduce((a, b) => a + b, 0) / vals.length;
}
function institutionalView(rows) {
let withAum = rows.filter(r => r.aumB != null).sort((a, b) => b.aumB - a.aumB);
let aumProxy = false;
if (!withAum.length) {
aumProxy = true;
withAum = [...rows]
.filter(r => r.price != null)
.sort((a, b) => (b.flowScore || 0) - (a.flowScore || 0))
.map((r, i, arr) => {
const w = Math.max(1, Math.abs(r.flowScore || 0) + Math.abs(r.rs20 || 0) + 1);
return { etf: r.etf, nameZh: r.nameZh, aumB: w, sharePct: null, _rank: i };
});
const sum = withAum.reduce((s, r) => s + r.aumB, 0);
withAum = withAum.map(r => ({ ...r, sharePct: sum ? (r.aumB / sum) * 100 : null }));
}
const totalAum = withAum.reduce((s, r) => s + (r.aumB || 0), 0);
const byFlow = [...rows].filter(r => r.flowScore != null).sort((a, b) => b.flowScore - a.flowScore);
return {
totalAumB: totalAum || null,
aumProxy,
byAum: withAum.map(r => ({
etf: r.etf,
nameZh: r.nameZh,
aumB: r.aumB,
sharePct: r.sharePct ?? (totalAum ? (r.aumB / totalAum) * 100 : null),
})),
flowLeaders: byFlow.slice(0, 5).map(r => ({
etf: r.etf,
nameZh: r.nameZh,
flowScore: r.flowScore,
ret5d: r.ret5d,
volRatio: r.volRatio,
note: flowNote(r),
})),
flowLaggards: byFlow.slice(-3).reverse().map(r => ({
etf: r.etf,
nameZh: r.nameZh,
flowScore: r.flowScore,
ret5d: r.ret5d,
volRatio: r.volRatio,
note: flowNote(r),
})),
disclaimer: aumProxy
? 'ETF 總資產暫時無法連線取得,下表改以「流向動能分數」相對占比示意機構資金關注度(非實際 AUM。流向分數量能異常×5日報酬方向。'
: 'ETF 總資產為被動/機構配置規模 proxyYahoo totalAssets流向分數結合近 5 日報酬與成交量異常,非官方申報流向。',
};
}
function flowNote(r) {
const parts = [];
if (r.ret5d != null) parts.push(`5日 ${r.ret5d >= 0 ? '+' : ''}${r.ret5d.toFixed(1)}%`);
if (r.volRatio != null) parts.push(`量能 ${r.volRatio.toFixed(2)}× 均量`);
return parts.join(' · ');
}
function buildStockExposure(holdingsByEtf, rotation, rows) {
const packs = [];
const spy = holdingsByEtf[BENCHMARK];
if (spy?.length) {
packs.push({
etf: BENCHMARK,
nameZh: 'S&P 500 大盤',
reason: '指數與被動基金的核心配置,代表整體機構底倉。',
holdings: spy,
});
}
const ranked = (rotation?.ranked || []).slice(0, 3);
for (const r of ranked) {
const list = holdingsByEtf[r.etf];
if (!list?.length) continue;
const meta = rows.find(x => x.etf === r.etf);
packs.push({
etf: r.etf,
nameZh: meta?.nameZh || r.nameZh,
reason: `20 日相對大盤 RS ${r.rs20 != null ? (r.rs20 >= 0 ? '+' : '') + r.rs20.toFixed(1) + '%' : '—'},資金輪動偏強的板塊。`,
holdings: list,
});
}
const composite = {};
for (const p of packs) {
const w = p.etf === BENCHMARK ? 1 : 0.65;
for (const h of p.holdings) {
if (!composite[h.symbol]) composite[h.symbol] = { symbol: h.symbol, name: h.name, score: 0, refs: [] };
composite[h.symbol].score += (h.pct || 0) * w;
composite[h.symbol].refs.push(p.etf);
}
}
const topStocks = Object.values(composite)
.sort((a, b) => b.score - a.score)
.slice(0, 12)
.map(s => ({ ...s, refs: [...new Set(s.refs)] }));
return {
packs,
topStocks,
howToRead: '下方為各 ETF 最新公布的前十大持股(非即時買賣紀錄)。機構「買什麼」在實務上常透過 ETF 與指數基金間接持有;要看單一對沖基金最新建倉,需查 13F季報、約延遲 45 天)。',
disclaimer: '持股來自 Yahoo ETF topHoldings更新頻率通常為每月與 13F、Dark pool 即時流向不同。',
usedFallback: false,
};
}
export async function buildSectorFlowPayload() {
const symbols = [...SECTOR_ETFS.map(s => s.etf), BENCHMARK];
// 最先抓大盤持股(避免後續 Yahoo 請求過多被限流)
let earlyHoldings = {};
try {
resetYahooAuth();
earlyHoldings = await fetchEtfTopHoldings([BENCHMARK], 10);
} catch { /* optional */ }
const histories = await Promise.all(symbols.map(async sym => {
try {
const h = await getHistory(sym, '6mo', '1d');
return [sym, h.points];
} catch {
return [sym, null];
}
}));
const pts = Object.fromEntries(histories);
const spyPoints = pts[BENCHMARK];
if (!spyPoints?.length) throw new Error('無法取得 SPY 基準');
// 先算輪動,優先抓 ETF 持股(僅 4 檔、避免在 11 次 AUM 之後被 Yahoo 限流)
const prelimRows = SECTOR_ETFS.map(meta => {
const points = pts[meta.etf];
if (!points?.length) return { ...meta, error: 'no_data' };
return buildSectorRow(meta, points, spyPoints, null);
}).filter(Boolean);
const rotationPre = summarizeRotation(prelimRows.filter(s => !s.error));
const holdingEtfs = [BENCHMARK, ...(rotationPre.ranked || []).slice(0, 3).map(r => r.etf)];
const uniqueHoldEtfs = [...new Set(holdingEtfs)];
let stockExposure = null;
let holdingsUsedFallback = false;
try {
let holdingsByEtf = { ...earlyHoldings };
const needFetch = uniqueHoldEtfs.filter(s => !holdingsByEtf[s]);
if (needFetch.length) {
await yahooSleep(300);
const more = await fetchEtfTopHoldings(needFetch, 10);
holdingsByEtf = { ...holdingsByEtf, ...more };
}
const merged = mergeHoldingsWithFallback(holdingsByEtf, uniqueHoldEtfs);
holdingsByEtf = merged.out;
holdingsUsedFallback = merged.usedFallback;
if (Object.keys(holdingsByEtf).length) {
stockExposure = buildStockExposure(holdingsByEtf, rotationPre, prelimRows.filter(s => !s.error));
if (stockExposure && holdingsUsedFallback) {
stockExposure.disclaimer += ' SPY 持股在資料源限流時暫用近期常見權重示意。';
stockExposure.usedFallback = true;
}
}
} catch (err) {
console.warn('[sector-flow] topHoldings:', err?.message || err);
}
let aumMap = {};
try {
await yahooSleep(400);
aumMap = await fetchEtfAum(SECTOR_ETFS.map(s => s.etf));
} catch { /* AUM 可缺 */ }
const sectors = SECTOR_ETFS.map(meta => {
const points = pts[meta.etf];
if (!points?.length) return { ...meta, error: 'no_data' };
return buildSectorRow(meta, points, spyPoints, aumMap[meta.etf] ?? null);
}).filter(Boolean);
const rotation = summarizeRotation(sectors.filter(s => !s.error));
const okRows = sectors.filter(s => !s.error);
const institutional = institutionalView(okRows);
if (stockExposure && rotation?.leader) {
stockExposure = buildStockExposure(
Object.fromEntries((stockExposure.packs || []).map(p => [p.etf, p.holdings])),
rotation,
okRows,
);
}
return {
updatedAt: new Date().toISOString(),
benchmark: BENCHMARK,
source: 'Yahoo Finance · SPDR 行業 ETF',
sectors,
rotation,
institutional,
stockExposure,
heatmapWindow: { d1: '1日', d5: '5日', d20: '20日', d60: '約60日' },
};
}

View File

@ -1,80 +0,0 @@
// 追蹤個股:分群清單(持久化於 SQLite KV與財報日曆 watchlist 分開)
import { getCachedEntry, putCachedJSON } from './db.js';
const STORE_KEY = 'stock:watchlist:v1';
const MAX_GROUPS = 24;
const MAX_SYMBOLS_PER_GROUP = 48;
const MAX_SYMBOLS_TOTAL = 200;
export const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
function newGroupId() {
return `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export function defaultWatchlist() {
return {
groups: [
{ id: 'default', name: '我的追蹤', symbols: [], order: 0 },
],
updatedAt: new Date().toISOString(),
};
}
function cleanSymbol(s) {
const sym = String(s || '').trim().toUpperCase();
return SYMBOL_RE.test(sym) ? sym : null;
}
function normalizeGroup(g, idx) {
const id = String(g?.id || '').trim() || newGroupId();
const name = String(g?.name || '').trim().slice(0, 40) || '未命名分群';
const symbols = [...new Set((g?.symbols || []).map(cleanSymbol).filter(Boolean))].slice(0, MAX_SYMBOLS_PER_GROUP);
return { id, name, symbols, order: Number.isFinite(g?.order) ? g.order : idx };
}
/** 驗證並正規化前端API 送來的完整結構 */
export function normalizeWatchlistPayload(raw) {
const base = defaultWatchlist();
if (!raw || typeof raw !== 'object') return base;
let groups = Array.isArray(raw.groups) ? raw.groups.map(normalizeGroup) : base.groups;
if (!groups.length) groups = base.groups;
groups = groups.slice(0, MAX_GROUPS).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, 'zh-Hant'));
const seenSym = new Set();
for (const g of groups) {
g.symbols = g.symbols.filter(sym => {
if (seenSym.has(sym) || seenSym.size >= MAX_SYMBOLS_TOTAL) return false;
seenSym.add(sym);
return true;
});
}
if (!groups.some(g => g.id === 'default')) {
groups.unshift({ id: 'default', name: '我的追蹤', symbols: [], order: -1 });
}
groups.forEach((g, i) => { g.order = i; });
return { groups, updatedAt: new Date().toISOString() };
}
export function getStockWatchlist() {
const row = getCachedEntry(STORE_KEY);
const val = row?.value;
if (!val?.groups) return defaultWatchlist();
return normalizeWatchlistPayload(val);
}
export function saveStockWatchlist(payload) {
const normalized = normalizeWatchlistPayload(payload);
putCachedJSON(STORE_KEY, normalized);
return normalized;
}
export function allWatchlistSymbols(data) {
const out = [];
const seen = new Set();
for (const g of data?.groups || []) {
for (const sym of g.symbols || []) {
if (!seen.has(sym)) { seen.add(sym); out.push(sym); }
}
}
return out;
}

72
lib/yahoo-session.js vendored
View File

@ -1,72 +0,0 @@
// 共用 Yahoo Finance cookie/crumb避免多模組並行請求互相打掛
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
let _auth = { cookie: null, crumb: null, at: 0 };
let _inflight = null;
let _queue = Promise.resolve();
export function resetYahooAuth() {
_auth = { cookie: null, crumb: null, at: 0 };
}
export async function yahooAuth(force = false) {
if (!force && _auth.crumb && Date.now() - _auth.at < 3600e3) return _auth;
if (_inflight) return _inflight;
_inflight = (async () => {
const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null);
const cookie = (r1?.headers.get('set-cookie') || '').split(';')[0] || '';
const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', {
headers: { 'User-Agent': UA, Cookie: cookie },
});
const crumb = (await r2.text()).trim();
if (!crumb || crumb.includes('<')) throw new Error('Yahoo crumb');
_auth = { cookie, crumb, at: Date.now() };
return _auth;
})().finally(() => { _inflight = null; });
return _inflight;
}
async function yahooJson(url, retry = true) {
const { cookie, crumb } = await yahooAuth();
const sep = url.includes('?') ? '&' : '?';
const full = `${url}${sep}crumb=${encodeURIComponent(crumb)}`;
const res = await fetch(full, { headers: { 'User-Agent': UA, Cookie: cookie } });
if ((res.status === 401 || res.status === 429) && retry) {
resetYahooAuth();
await sleep(500);
await yahooAuth(true);
return yahooJson(url, false);
}
if (!res.ok) throw new Error(`Yahoo HTTP ${res.status}`);
return res.json();
}
function yahooQueued(fn) {
const run = _queue.then(() => fn());
_queue = run.catch(() => {});
return run;
}
/** quoteSummary 模組assetProfile、topHoldings 等)— 序列化避免並行打掛 crumb */
export async function yahooQuoteSummary(symbol, modules) {
return yahooQueued(async () => {
const mod = Array.isArray(modules) ? modules.join(',') : modules;
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${encodeURIComponent(mod)}`;
const j = await yahooJson(url);
await sleep(120);
return j?.quoteSummary?.result?.[0] || null;
});
}
export async function yahooFinanceSearchNews(symbol, count = 12) {
return yahooQueued(async () => {
const url = `https://query1.finance.yahoo.com/v1/finance/search?q=${encodeURIComponent(symbol)}&newsCount=${count}&quotesCount=0`;
const j = await yahooJson(url);
await sleep(120);
return j?.news || [];
});
}
export function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}

373
server.js
View File

@ -22,26 +22,18 @@ import {
saveScoreSnapshot, getScoreHistory, saveScoreSnapshot, getScoreHistory,
listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats, listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
getCachedJSON, putCachedJSON, getCachedEntry, getCachedJSON, putCachedJSON, getCachedEntry,
getCompanyIntelCustom, saveCompanyIntelCustom,
} from './lib/db.js'; } from './lib/db.js';
import { mergeCustomIntel, localizeIntel } from './lib/companyintel-i18n.js';
import { ensurePriceHistory, buildVolumeSeries } from './lib/price-store.js';
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js'; import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js'; import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildReport } from './lib/fincheck.js'; import { buildReport } from './lib/fincheck.js';
import { RANGES, INTERVALS } from './lib/marketdata.js'; import { getHistory, getHistorySince, RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js'; import { runBacktest, STRATEGIES } from './lib/backtest.js';
import { getInvestMap } from './lib/investmap.js'; import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js'; import { buildGraph } from './lib/graph.js';
import { import {
getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache, getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache,
} from './lib/calendar-cache.js'; } from './lib/calendar-cache.js';
import { getCompanyIntel, runCompanyIntelSync } from './lib/companyintel.js'; import { getCompanyIntel } from './lib/companyintel.js';
import { syncSecArchive, getSecArchivePayload, resolveArchiveFile } from './lib/sec-archive.js';
import { buildSectorFlowPayload } from './lib/sector-flow.js';
import {
getStockWatchlist, saveStockWatchlist, normalizeWatchlistPayload, allWatchlistSymbols,
} from './lib/watchlist.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(__dirname, '.env'); const ENV_PATH = path.join(__dirname, '.env');
@ -60,7 +52,6 @@ const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000;
const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1000; const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1000;
const PROFILE_TTL_MS = (Number(process.env.PROFILE_TTL_HOURS) || 24) * 3600 * 1000; const PROFILE_TTL_MS = (Number(process.env.PROFILE_TTL_HOURS) || 24) * 3600 * 1000;
const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000; const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000;
const SECTOR_TTL_MS = (Number(process.env.SECTOR_TTL_HOURS) || 6) * 3600 * 1000;
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/; const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here'; const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
@ -176,26 +167,6 @@ app.get('/api/macro', async (req, res) => {
// 歷史事件標記 & 危機案例(靜態設定,給走勢標註與「歷史殷鑑」頁用) // 歷史事件標記 & 危機案例(靜態設定,給走勢標註與「歷史殷鑑」頁用)
app.get('/api/events', (req, res) => res.json({ events: EVENTS, episodes: EPISODES })); app.get('/api/events', (req, res) => res.json({ events: EVENTS, episodes: EPISODES }));
app.get('/api/sectors', async (req, res) => {
const key = 'sectors:flow:v1';
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < SECTOR_TTL_MS) {
return res.json({ ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() });
}
const payload = await buildSectorFlowPayload();
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/sectors]', err?.message || err);
if (entry?.value) {
return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
}
res.status(502).json({ error: 'sectors_failed', message: String(err?.message || err) });
}
});
// 單一指標歷史序列(給走勢大圖) // 單一指標歷史序列(給走勢大圖)
const RANGE_DAYS = { '1m': 30, '6m': 182, '1y': 365, '5y': 1825, '10y': 3650, max: null }; const RANGE_DAYS = { '1m': 30, '6m': 182, '1y': 365, '5y': 1825, '10y': 3650, max: null };
app.get('/api/series/:key', (req, res) => { app.get('/api/series/:key', (req, res) => {
@ -288,82 +259,35 @@ function trimHistoryRange(payload, range) {
const since = new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10); const since = new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10);
return { ...payload, points: payload.points.filter(p => p.date >= since) }; return { ...payload, points: payload.points.filter(p => p.date >= since) };
} }
async function enrichTodayVolume(payload, symbol, refreshQuote) { function mergeHistory(oldPayload, patchPayload) {
if (payload.interval !== '1d') return payload; const map = new Map();
let quote = getCachedEntry(`quote:${symbol}`)?.value || {}; for (const p of (oldPayload.points || [])) map.set(p.date, p);
const needQuote = refreshQuote || quote.volume == null; for (const p of (patchPayload.points || [])) map.set(p.date, p);
if (needQuote) { const points = [...map.values()].sort((a, b) => a.date < b.date ? -1 : 1);
try {
const q = await getQuote(symbol);
quote = { symbol, ...q };
putCachedJSON(`quote:${symbol}`, { ...quote, _fetchedAt: Date.now() });
} catch (_) { /* 沿用快取 */ }
}
const volumePoints = buildVolumeSeries(
payload.points,
payload.allBarsPoints || payload.points,
quote,
'1d',
);
const today = new Date().toISOString().slice(0, 10);
const todayBar = volumePoints.find(p => p.date === today);
const todayVolume = todayBar?.volume ?? quote.volume ?? null;
const avgVolume = quote.avgVolume ?? null;
return { return {
...payload, ...oldPayload,
volumePoints, ...patchPayload,
todayVolume, points,
avgVolume, _lastIncrementalAt: Date.now(),
volumeRatio: todayVolume != null && avgVolume ? todayVolume / avgVolume : null, _incremental: true,
volumeNote: todayBar?.partialSession
? '當日成交量來自即時報價(收盤 K 仍截至昨日完整棒)'
: (todayVolume != null ? '含當日成交量' : null),
}; };
} }
async function getHistoryCached(symbol, range, interval, fresh) { async function getHistoryCached(symbol, range, interval, fresh) {
const ttl = interval === '1mo' ? 7 * 24 * 3600 * 1000 const key = `hist:${symbol}:${range}:${interval}`;
: interval === '1wk' ? 24 * 3600 * 1000 const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000;
: HIST_TTL_MS; const entry = getCachedEntry(key);
if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...trimHistoryRange(entry.value, range), cached: true };
try { try {
const { payload, cached, fetchMode } = await ensurePriceHistory(symbol, interval, { let hist;
fresh: fresh === true, const oldPoints = entry?.value?.points || [];
ttlMs: ttl, const lastDate = oldPoints.length ? oldPoints[oldPoints.length - 1].date : null;
}); if (lastDate) hist = mergeHistory(entry.value, await getHistorySince(symbol, lastDate, range, interval));
let enriched = await enrichTodayVolume(payload, symbol, fresh === true); else hist = await getHistory(symbol, range, interval);
const trimmed = trimHistoryRange({ ...enriched, range }, range); const payload = { ...hist, _fetchedAt: Date.now() };
if (trimmed.volumePoints) { putCachedJSON(key, payload);
const since = PRICE_RANGE_DAYS[range] return { ...trimHistoryRange(payload, range), cached: false };
? new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10)
: null;
trimmed.volumePoints = since
? trimmed.volumePoints.filter(p => p.date >= since)
: trimmed.volumePoints;
}
return {
...trimmed,
cached,
stale: !!payload.fetchError,
fetchError: payload.fetchError || null,
fetchMode,
dbBars: payload.dbBars,
researchBars: payload.researchBars,
researchThrough: payload.researchThrough,
researchNote: payload.researchNote,
firstDate: payload.firstDate,
lastDate: payload.lastDate,
};
} catch (err) { } catch (err) {
const legacyKey = `hist:${symbol}:max:${interval}`; if (entry) return { ...trimHistoryRange(entry.value, range), cached: true, stale: true, fetchError: String(err?.message || err) };
const entry = getCachedEntry(legacyKey);
if (entry) {
return {
...trimHistoryRange({ ...entry.value, range }, range),
cached: true,
stale: true,
fetchError: String(err?.message || err),
};
}
throw err; throw err;
} }
} }
@ -422,21 +346,6 @@ app.get('/api/profile/:symbol', async (req, res) => {
} }
}); });
function intelPayloadStale(payload, symbol) {
if (!payload) return true;
const goog = (payload.management?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''))
|| (payload.industryChain?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''));
if (goog) return true;
const us = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.');
if (us && !(payload.resources || []).length) return true;
const groups = [...(payload.industryChain?.upstreamDetail || []), ...(payload.industryChain?.downstreamDetail || [])];
const hasObjEntities = groups.some(g => (g.entities || []).some(e => e && typeof e === 'object' && e.symbol));
if (groups.length && !hasObjEntities) return true;
if ((payload.industryChain?.peers || []).length > 0) return true;
if (payload.chainLayout !== 'upstream_downstream_v2') return true;
return false;
}
app.get('/api/company-intel/:symbol', async (req, res) => { app.get('/api/company-intel/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase(); const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' }); if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
@ -444,27 +353,11 @@ app.get('/api/company-intel/:symbol', async (req, res) => {
const entry = getCachedEntry(key); const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1'; const fresh = req.query.fresh === '1';
try { try {
const cacheOk = !fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS && !intelPayloadStale(entry.value, symbol); if (!fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS) return res.json({ ...entry.value, cached: true });
if (cacheOk) {
const custom = getCompanyIntelCustom(symbol);
const { sanitizeIntelNewsPayload } = await import('./lib/companyintel.js');
let payload = custom?.data
? mergeCustomIntel(localizeIntel(entry.value), custom.data)
: entry.value;
payload = sanitizeIntelNewsPayload(payload);
const { attachIntelSyncStatus } = await import('./lib/companyintel-ai.js');
payload = attachIntelSyncStatus(payload, symbol);
return res.json({ ...payload, cached: true });
}
const profile = getCachedEntry(`profile:${symbol}`)?.value || {}; const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
const doSync = req.query.sync === '1'; const payload = await getCompanyIntel(symbol, profile);
const payload = await getCompanyIntel(symbol, profile, {
sync: doSync,
force: fresh,
useAI: req.query.ai !== '0',
});
putCachedJSON(key, payload); putCachedJSON(key, payload);
res.json({ ...payload, cached: false, synced: doSync }); res.json({ ...payload, cached: false });
} catch (err) { } catch (err) {
console.error('[api/company-intel]', symbol, err?.message || err); console.error('[api/company-intel]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) }); if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
@ -472,95 +365,6 @@ app.get('/api/company-intel/:symbol', async (req, res) => {
} }
}); });
app.put('/api/company-intel/:symbol/custom', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const body = req.body;
if (!body || typeof body !== 'object') {
return res.status(400).json({ error: 'bad_body', message: '請提供 JSON 物件。' });
}
try {
const saved = saveCompanyIntelCustom(symbol, body);
const intelKey = `intel:${symbol}`;
const entry = getCachedEntry(intelKey);
if (entry?.value) {
putCachedJSON(intelKey, mergeCustomIntel(localizeIntel(entry.value), body));
}
res.json({ ok: true, symbol: saved.symbol, updatedAt: saved.updatedAt });
} catch (err) {
res.status(400).json({ error: 'save_failed', message: String(err?.message || err) });
}
});
app.get('/api/company-intel/:symbol/custom', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const row = getCompanyIntelCustom(symbol);
res.json(row ? { symbol, data: row.data, updatedAt: row.updatedAt } : { symbol, data: null });
});
app.post('/api/company-intel/:symbol/sync', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const force = req.query.fresh === '1' || req.body?.force === true;
const useAI = req.body?.useAI !== false && req.query.ai !== '0';
try {
const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
const result = await runCompanyIntelSync(symbol, profile, { force, useAI });
const intelKey = `intel:${symbol}`;
const payload = result.skipped
? await getCompanyIntel(symbol, profile, { sync: false })
: await getCompanyIntel(symbol, profile, { sync: false, force: true });
putCachedJSON(intelKey, payload);
res.json({
ok: true,
symbol,
skipped: result.skipped,
skipReason: result.skipReason || null,
nextRefreshAfter: result.nextRefreshAfter || payload.nextRefreshAfter,
nextPublicLabel: result.nextPublicLabel || payload.nextPublicLabel,
aiError: result.aiError || null,
sources: result.sources,
enrichedAt: payload.enrichedAt,
intel: payload,
});
} catch (err) {
console.error('[api/company-intel/sync]', symbol, err?.message || err);
res.status(502).json({ error: 'intel_sync_failed', message: String(err?.message || err) });
}
});
app.get('/api/sec-archive/:symbol', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
res.json(getSecArchivePayload(symbol));
});
app.post('/api/sec-archive/:symbol/sync', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const force = req.query.fresh === '1' || req.body?.force === true;
try {
const payload = await syncSecArchive(symbol, { force });
res.json(payload);
} catch (err) {
console.error('[api/sec-archive/sync]', symbol, err?.message || err);
res.status(502).json({ error: 'sec_archive_failed', message: String(err?.message || err) });
}
});
app.get('/api/sec-archive/:symbol/file', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
const accession = String(req.query.accession || '').trim();
const file = String(req.query.file || '').trim();
if (!SYMBOL_RE.test(symbol) || !accession) {
return res.status(400).json({ error: 'bad_request', message: '需要 accession。' });
}
const full = resolveArchiveFile(symbol, accession, file || undefined);
if (!full) return res.status(404).json({ error: 'not_found', message: '本機尚無此檔案,請先同步封存。' });
res.sendFile(full);
});
function addDaysISO(base, days) { function addDaysISO(base, days) {
const d = new Date(base + 'T00:00:00Z'); const d = new Date(base + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days); d.setUTCDate(d.getUTCDate() + days);
@ -578,51 +382,6 @@ app.put('/api/calendar/watchlist', (req, res) => {
const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase()))); const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase())));
res.json({ ok: true, symbols }); res.json({ ok: true, symbols });
}); });
app.get('/api/watchlist', (req, res) => {
const data = getStockWatchlist();
res.json({ ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.put('/api/watchlist', (req, res) => {
const data = saveStockWatchlist(req.body);
res.json({ ok: true, ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.get('/api/watchlist/quotes', async (req, res) => {
const symbols = [...new Set(String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(s => SYMBOL_RE.test(s)))].slice(0, 48);
if (!symbols.length) return res.json({ quotes: [] });
const quotes = await Promise.all(symbols.map(async (symbol) => {
try {
const key = `quote:${symbol}`;
let q = getCachedEntry(key)?.value;
if (q?.price == null) {
q = await getQuote(symbol);
putCachedJSON(key, { symbol, ...q, _fetchedAt: Date.now() });
}
let chg = q?.changePercent;
if (chg == null) {
try {
const h = await getHistoryCached(symbol, '3mo', '1d', false);
const p = h?.points || [];
if (p.length >= 2 && p[0].close > 0) {
chg = ((p[p.length - 1].close / p[0].close) - 1) * 100;
}
} catch { /* skip */ }
}
return {
symbol,
name: q?.name || q?.shortName || symbol,
price: q?.price ?? null,
change: q?.change ?? null,
changePercent: chg ?? null,
currency: q?.currency || 'USD',
};
} catch (e) {
return { symbol, error: String(e?.message || e) };
}
}));
res.json({ quotes });
});
app.get('/api/calendar', async (req, res) => { app.get('/api/calendar', async (req, res) => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const start = /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : today; const start = /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : today;
@ -733,26 +492,6 @@ function compactForPrompt(v, max = 16000) {
const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2); const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
return s.length > max ? s.slice(0, max) + '\n...(上下文已截斷)' : s; return s.length > max ? s.slice(0, max) + '\n...(上下文已截斷)' : s;
} }
/** 依實際附帶的資料決定 page / chat避免「在資料頁但沒資料」仍強制套用分析格式 */
function finalizeAIContext(ctx = {}) {
const view = String(ctx.view || '').trim();
let hasPageData = false;
if (view === 'macro') {
const m = ctx.macro;
hasPageData = !!(m && (m.score != null || m.focusedCard || (m.signals && m.signals.length)));
} else if (view === 'stock') {
const s = ctx.stock;
hasPageData = !!(s && !s.error && (s.fundamentals || s.quote || (s.history?.points?.length > 0) || s.technical?.close != null));
} else if (view === 'calendar') {
hasPageData = !!(ctx.calendar?.events?.length);
} else if (view === 'journal') {
hasPageData = !!(ctx.journal?.trades?.length || ctx.journal?.stats);
} else if (view === 'learn') {
const n = ctx.learning?.focusedNote;
hasPageData = !!(n?.body || n?.title || (ctx.learning?.visibleText || '').trim().length > 80);
}
return { ...ctx, view, hasPageData, mode: hasPageData ? 'page' : 'chat' };
}
function cachedValue(entry) { function cachedValue(entry) {
if (!entry) return null; if (!entry) return null;
return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() }; return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() };
@ -812,21 +551,15 @@ async function stockAIContext(symbol, focus, allowFetch) {
quoteEntry = getCachedEntry(`quote:${symbol}`); quoteEntry = getCachedEntry(`quote:${symbol}`);
out.sources.push('quote:fetched'); out.sources.push('quote:fetched');
} }
let histPayload = null; let histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
if (allowFetch) { if (!histEntry && allowFetch) {
try { await getHistoryCached(symbol, 'max', '1d', false).catch(() => null);
const h = await ensurePriceHistory(symbol, '1d', { fresh: false, ttlMs: HIST_TTL_MS }); histEntry = getCachedEntry(`hist:${symbol}:max:1d`);
histPayload = h.payload; if (histEntry) out.sources.push('history:fetched');
out.sources.push(h.fetchMode ? `history:${h.fetchMode}` : 'history:db');
} catch (_) { /* 允許缺歷史 */ }
} }
const fundamentals = cachedValue(fundEntry); const fundamentals = cachedValue(fundEntry);
const quote = cachedValue(quoteEntry); const quote = cachedValue(quoteEntry);
const history = histPayload ? { const history = cachedValue(histEntry);
...histPayload,
cached: true,
cachedAt: histPayload._fetchedAt ? new Date(histPayload._fetchedAt).toISOString() : null,
} : null;
out.fundamentals = fundamentals ? { out.fundamentals = fundamentals ? {
symbol: fundamentals.symbol, symbol: fundamentals.symbol,
name: fundamentals.name, name: fundamentals.name,
@ -846,12 +579,14 @@ async function stockAIContext(symbol, focus, allowFetch) {
out.cacheStatus = { out.cacheStatus = {
fundamentals: !!fundEntry, fundamentals: !!fundEntry,
quote: !!quoteEntry, quote: !!quoteEntry,
history: !!(histPayload?.points?.length), history: !!histEntry,
}; };
return out; return out;
} }
async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) { async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) {
const base = { const base = {
mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat',
hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view),
view, view,
focus, focus,
client, client,
@ -867,10 +602,7 @@ async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch =
base.macro = summarizeMacro(saved?.payload || cache.payload, focus); base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
} else if (view === 'stock') { } else if (view === 'stock') {
const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase(); const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase();
if (symbol) { if (symbol) base.stock = await stockAIContext(symbol, focus, allowFetch);
base.stock = await stockAIContext(symbol, focus, allowFetch);
if (client.technical) base.stock.technical = client.technical;
}
} else if (view === 'calendar') { } else if (view === 'calendar') {
const symbols = getCalendarWatchlist(); const symbols = getCalendarWatchlist();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@ -917,7 +649,7 @@ async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch =
personalNotes: client.personalNotes || [], personalNotes: client.personalNotes || [],
}; };
} }
return finalizeAIContext(base); return base;
} }
function normalizeModelList(data) { function normalizeModelList(data) {
const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : []; const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : [];
@ -977,7 +709,7 @@ app.post('/api/ai/chat', async (req, res) => {
const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim(); const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim(); let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim();
const question = String(req.body?.question || '').trim(); const question = String(req.body?.question || '').trim();
const context = finalizeAIContext(req.body?.context || {}); const context = req.body?.context || {};
if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' }); if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
if (!model) { if (!model) {
const models = await listProviderModels(provider, apiKey).catch(() => []); const models = await listProviderModels(provider, apiKey).catch(() => []);
@ -985,24 +717,25 @@ app.post('/api/ai/chat', async (req, res) => {
} }
if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' }); if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' });
if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' }); if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' });
const hasPageData = context.hasPageData === true; const hasPageData = context?.mode === 'page' || context?.hasPageData === true;
const system = hasPageData ? [ const system = hasPageData ? [
'你是 MacroScope 的投資學習助理。', '你是 MacroScope 的投資學習助理。',
'使用者正在 App 某個頁面提問,並附上該頁可取得的結構化資料(可能不完整)。', '使用者正在帶著頁面上下文提問,請根據提供的財報、總經、學習資料或交易復盤做分析與對照。',
'請優先根據附帶資料回答;資料未提及的不要捏造。語氣自然,像教學對話即可,不必強制固定章節格式,除非使用者要求條列或摘要。', '請用繁體中文回答,先給結論,再列出依據、矛盾點、下一步可以在頁面上檢查什麼。',
'若資料不足以回答,請直接說明缺什麼、建議在畫面上查看哪裡。', '不要聲稱已即時查網路;若資料不足,要明確說資料不足。',
'不要聲稱已即時查網路。',
'內容僅供學習,不構成投資建議。', '內容僅供學習,不構成投資建議。',
].join('\n') : [ ].join('\n') : [
'你是 MacroScope 的 AI 助手。', '你是 MacroScope 的 AI 助手。',
'這是一般對話,沒有附帶頁面結構化資料。請用繁體中文自然回答,像一般聊天即可。', '目前沒有可用頁面資料,請把這次對話當一般聊天或一般投資學習問答處理。',
'若使用者問投資或財務判斷,可給教學性說明,並提醒僅供學習、不構成投資建議。', '請用繁體中文自然回答;如果使用者問投資或財務判斷,要提醒內容僅供學習,不構成投資建議。',
'需要本 App 內的總經、個股、日曆或筆記資料時,請建議使用者切到對應頁面後再問。', '不要聲稱已即時查網路;需要即時資料時,請說明你需要使用者提供資料或切到相關頁面。',
'不要聲稱已即時查網路。', ].join('\n');
const user = [
`使用者問題:${question}`,
'',
hasPageData ? '目前頁面上下文:' : '對話狀態:',
hasPageData ? compactForPrompt(context) : compactForPrompt({ mode: 'chat', view: context?.view || '', collectedAt: context?.collectedAt || '' }),
].join('\n'); ].join('\n');
const user = hasPageData
? [`使用者問題:${question}`, '', '目前頁面上下文JSON', compactForPrompt(context)].join('\n')
: [`使用者問題:${question}`, '', `目前所在視圖:${context.view || '(未知)'}(無可用頁面資料,請當一般對話)`].join('\n');
try { try {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 120000); const timer = setTimeout(() => ctrl.abort(), 120000);