Compare commits
No commits in common. "feat/grok" and "main" have entirely different histories.
|
|
@ -4,5 +4,4 @@ node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
data.db
|
data.db
|
||||||
data.db-*
|
data.db-*
|
||||||
archive/
|
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
|
||||||
470
README.md
470
README.md
|
|
@ -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、三段白話解釋、資料來源與更新頻率。
|
|
||||||
- **總經健康分數**(0–100)與景氣燈號,附完整 breakdown。
|
|
||||||
- 殖利率曲線 + 歷史事件標記(`EVENTS` / `EPISODES`)。
|
|
||||||
- 點卡片開走勢大圖 Modal,支援多區間與分數累積走勢。
|
|
||||||
|
|
||||||
### 市場日曆
|
|
||||||
|
|
||||||
- 未來約 60 天重要事件(央行、通膨、就業、四巫日、休市等)。
|
|
||||||
- **追蹤財報**:自訂股票代號 watchlist,earnings 顯示在對應日期。
|
|
||||||
- 多源抓取(官方 iCal、FRED、BLS、BEA、Nasdaq),伺服器每日快取。
|
|
||||||
|
|
||||||
### 學習教材
|
|
||||||
|
|
||||||
- 由 `emmy/emmy` Obsidian 知識庫建置快照(原則、案例、名詞、公司、單集、知識圖譜)。
|
|
||||||
- wikilink `[[目標]]` 站內跳轉、Markdown + Mermaid、個人筆記(localStorage)。
|
|
||||||
- 與總經、個股、復盤雙向連結。
|
|
||||||
|
|
||||||
### 個股工具
|
|
||||||
|
|
||||||
- **指標面板**:報價、三表、比率、Company Intel。
|
|
||||||
- **價格走勢**:多區間/日週月線圖(Yahoo 主力,Nasdaq 備援)。
|
|
||||||
- **財報健檢**:五步驟紅黃綠燈 + 連結知識庫。
|
|
||||||
- **投資地圖**:六層漏斗互動流程。
|
|
||||||
- **回測**:買進持有 / 定期定額 / 均線 / 逢大跌等策略。
|
|
||||||
|
|
||||||
### 交易復盤
|
|
||||||
|
|
||||||
- CRUD 交易紀錄,自動損益、勝率、Payoff、分組分析。
|
|
||||||
- 資料存於 SQLite `trades` 表。
|
|
||||||
|
|
||||||
### AI 助手
|
|
||||||
|
|
||||||
- Provider:Grok、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 # SQLite:cache / 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 + Express(server.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/Nasdaq(DB 快取)→ 收盤線圖`
|
||||||
| `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 ≥ 18(ESM) |
|
|
||||||
| 後端 | 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 分(中性)** 出發,依殖利率曲線、衰退機率、通膨、就業、信用利差等規則加減,限制 0–100。中性參考指標(美元、油價等)不計分。
|
## 總經健康分數怎麼算?
|
||||||
|
|
||||||
| 分數 | 燈號 |
|
從 50 分(中性)出發,依殖利率曲線、衰退機率、通膨、就業、信用利差、金融條件、製造業、
|
||||||
|------|------|
|
成長、波動率等規則加減分,最後限制在 0–100。每一條規則都會列在分數的「?」說明裡,
|
||||||
| ≥ 65 | 景氣穩健 |
|
方向中性的指標(如美元、油價、股市本身)不計入分數,只作為參考。
|
||||||
| 50–64 | 溫和成長 |
|
|
||||||
| 35–49 | 景氣放緩 |
|
|
||||||
| < 35 | 衰退風險高 |
|
|
||||||
|
|
||||||
教學用簡化模型,**不構成投資建議**。
|
- 65 分以上:景氣穩健
|
||||||
|
- 50–64:溫和成長
|
||||||
|
- 35–49:景氣放緩
|
||||||
|
- 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
679
app.css
|
|
@ -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%}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
232
index.html
232
index.html
|
|
@ -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&§orRes.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)});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
// 伺服器端呼叫已設定的 AI Provider(OpenCode 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: 'EDA/IP', 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;頁面只顯示上游、下游兩欄)。',
|
|
||||||
`upstream:2~5 組供應商;每組 entities 為 2~6 個具體公司名或股票代號(美股 1-5 字大寫;台股 2330.TW)。`,
|
|
||||||
`downstream:2~4 組「誰購買 ${symbol} 的產品或服務」;必須具名客戶(公司名或代號),禁止只寫終端客戶、企業客戶、通路等泛稱;優先採用 10-K customers 與新聞中的買方。`,
|
|
||||||
/NVDA|AMD/i.test(symbol)
|
|
||||||
? 'GPU 範例下游:DELL、HPE、SMCI(AI 伺服器 OEM,採購 GPU 組裝再銷售)、MSFT、AMZN、GOOGL、META(雲端部署);同業 AMD 放 peers 勿放 downstream。'
|
|
||||||
: null,
|
|
||||||
'peers:3~8 個同業代號。',
|
|
||||||
'每個 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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', '領導團隊'),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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"esCount=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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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)抓高管:Yahoo/10-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(/ /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
249
lib/db.js
|
|
@ -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'];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
lib/glossary.js
104
lib/glossary.js
|
|
@ -53,11 +53,6 @@ const TERM_TIPS = {
|
||||||
what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。',
|
what: '用兩條不同速度的均線相減,看動能是在變強還是變弱。',
|
||||||
how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。',
|
how: '柱狀圖由負轉正,常被解讀為動能轉多;由正轉負則偏空。適合搭配趨勢一起看。',
|
||||||
},
|
},
|
||||||
kdj: {
|
|
||||||
label: 'KDJ',
|
|
||||||
what: '隨機指標的變體:看收盤價在一段高低區間裡的位置,再平滑成 K、D,J=3K−2D。',
|
|
||||||
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 啟發式調整;折現率 8–13%;終值成長 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% + 波動/槓桿調整(8–13%)\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: '專業軟體通常主圖看價+均線,副圖一次開 1~2 個(量、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));
|
||||||
|
|
|
||||||
|
|
@ -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('無法取得增量歷史股價');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
|
||||||
'<': '<', '>': '>', '&': '&', '"': '"', ''': "'", ''': "'",
|
|
||||||
' ': ' ', ' ': ' ',
|
|
||||||
};
|
|
||||||
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 /<|>|&#|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);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
|
||||||
// 類 RRG:X=相對大盤強度,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 總資產為被動/機構配置規模 proxy(Yahoo 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日' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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}"esCount=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
373
server.js
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue