跳轉到

主動式 ETF 綜合日報

本日報先由程式計算可驗證的事實,再交給 LLM 從候選股選出 3–5 檔重點並安排段落;不足 3 檔時全部保留。LLM 以句子級事實片語組合自然銜接,股票/ETF 名稱與代號、方向、張數、估算金額及雙向操作均由程式片語展開。LLM 只在排程時段送出(見「LLM、快取與排程」);其他時候、以及模型未設定、逾時、呼叫失敗或輸出不合格時,都回傳同樣精簡的固定模板。

產生與讀取

POST /api/etf-holdings/recap/daily/analyze

管理員 Bearer Token,無 request body。date 為可選 ISO 日期,預設台北今日;force 為可選 boolean,預設 false。force=false 只回傳同一份輸入的既有快取,或產生並保存固定模板(fallback_reason=llm_deferred),不呼叫 LLM。force=true 略過快取並立刻送一次 LLM,當作手動補救,記錄為 manual,不占用每日排程名額;若送出失敗而同一份輸入已有 LLM 報告,回傳該報告不變(generated_at 不更新)。例如:

POST /api/etf-holdings/recap/daily/analyze?date=2026-09-24&force=false
Authorization: Bearer <admin-token>

休市日解析為前一個官方交易日;台北今日 15:30 以前解析為前一交易日,15:30 起可產出今日部分資料報告。超出官方日曆範圍或未來日期回 422。價格與持股都沒有可比較樣本時回 404,錯誤訊息為 active ETF recap data not available。來源、資料庫或持久化錯誤會正常失敗,不當成 LLM fallback。缺少必要 Authorization header 回 422;無效登入憑證/非管理員沿用既有 401/403 權限契約。

GET /api/recap-analysis/latest?analysis_type=active_etf&market=taiwan

公開、唯讀、只讀取最新 daily 快取,不呼叫 LLM。新增 active_etf 列舉值;原有四類日報的欄位、wrapper 與行為不變。active_etf 僅支援 market=taiwan,其他市場回 422。沒有快取、快取格式不合法或內外交易日不一致,回 404,錯誤訊息為 latest market recap analysis not available。

兩個端點成功回應均為 200 JSON,下例縮略 facts 與正文:

{
  "analysis_type": "active_etf",
  "market": "taiwan",
  "trade_date": "2026-09-24",
  "period_type": "daily",
  "generated_at": "2026-09-28T00:00:00Z",
  "analysis": {
    "trade_date": "2026-09-24",
    "period_type": "daily",
    "headline": "主動式股票 ETF 日報|2026-09-24",
    "report": "主動式 ETF 報價……",
    "generation_mode": "template",
    "fallback_reason": "llm_unavailable",
    "facts": {}
  }
}

report 是以空行分段的繁體中文完整正文;headline 是標題。generation_mode=llm 時,fallback_reason=null;generation_mode=template 時,原因必為 llm_deferred、llm_unavailable、llm_timeout、llm_error 或 invalid_response。llm_deferred 表示這次生成依每日排程沒有呼叫 LLM,不是失敗。資料缺漏與生成模式分開看,模板也可能擁有完整資料,LLM 報告也可能只涵蓋部分 ETF。

FastAPI validation errors及應用程式錯誤沿用既有錯誤 envelope,例如資料不足為:

{"error":{"code":"active ETF recap data not available","message":"active ETF recap data not available","details":{}}}

facts 欄位與單位

持股無可比較樣本時,正文會顯示「本交易日持股變動資料不足,暫無法計算增減持金額」;此時 totals 的零不能解讀成沒有異動。有可比較樣本且買進、賣出總額皆為零時,只有未缺少 ETF 快照、有效數量、成分股識別或當日個股價格才顯示「本交易日未見持股增減」;有缺漏則沿用持股變動資料不足的句子。境外/非股票資產的刻意排除與僅影響歷史趨勢的缺價不影響此判斷。數量與識別排除計數合併當日及歷史區間,因此零總額時採保守判斷。雙方總額相同且大於零時才顯示「整體增減持金額相抵」。金額欄位均為新台幣元的 JSON number,quantity/quantity_delta 為股數,一張為 1,000 股;文字中的萬/億只影響顯示。所有金額是估算值,非基金實際成交額。

欄位 內容
trade_date, previous_date, market 報告日、前一官方交易日及 taiwan
price_summary current_date, previous_date, tracked_etfs, comparable_etfs, up_count, down_count, flat_count, mean_return_pct;無可比較價格時平均為 null
performance[] etf_ticker, etf_name, previous_close, current_close, change, return_pct, direction(up/down/flat)
coverage tracked_etfs, fresh_etfs, comparable_etfs, missing_etfs[];缺漏項含 ETF 代號、名稱與 reason
totals buy_amount, sell_amount 為正數;net_amount=buy_amount-sell_amount
stocks[] 所有有效異動股票,不受正文精選數量限制;結構見下表
featured_stock_ids[] 正文候選股票的 ticker,預設最多 8 檔;正文最多選取 5 檔
trends 六個 session_dates、五個 intervals、固定樣本 eligible_etfs[]、excluded_etfs[]、獨立精選 featured_stock_ids[] 及各股票 stocks[]
exclusions action_count, trend_count、原因別 counts 及含 code/count/description 的 reasons[]
disclaimers[] 持股增減、估價及範圍的說明
stocks[] 欄位 內容
stock_id, ticker, name, current_close 公開股票識別、名稱(可能 null)、精確報告日收盤價
buy_actions[], sell_actions[] 各 ETF 的 etf_ticker, etf_name(可能 null), quantity_delta(帶正負), lots(絕對值), amount(絕對值), change_type
change_type added, removed, quantity_increased, quantity_decreased
buy_amount, sell_amount, net_amount 雙方估算金額及淨額,不能用 ETF 檔數取代金額判斷
net_quantity, net_lots, direction 帶正負的淨股數/張數;net_buy/net_sell/balanced
pattern divergence/coordinated_buy/coordinated_sell/single_fund_buy/single_fund_sell
divergence_ratio_pct 雙向時 min(買方金額,賣方金額)/max(買方金額,賣方金額)×100;單向時 0
is_new 是否至少一檔 ETF 出現新增持股

雙向操作在正文具名列出雙方的張數、預估金額與淨結果,不能因為淨買進而省略賣方,也不推論經理人信心。正文與跨股觀察每次提及股票/ETF 名稱皆顯示「名稱(代號)」,例如台積電(2330);JSON 識別仍保留市場尾碼。分歧比例保留在 facts,正文不堆疊該數字。

trends.stocks[] 包含 stock_id/ticker/name、五筆帶正負 signed_lots/estimated_amounts、逐日 daily[](previous_date/date/net_quantity/signed_lots/estimated_amount/direction)、streak(direction=buy/sell/none、sessions)及 reversal(五個交易日非零方向是否曾改變)。各日估算使用各日收盤價;不以今日股價乘整段股數。eligible_etfs 與 excluded_etfs 逐檔列出名稱/代號,後者另含排除原因。

計算與資料完整度

範圍是已啟用、主動管理、六碼代號第六碼為 A 的股票型 ETF。此篩選依據臺灣證券交易所主動式 ETF 編碼規則:第六碼 A 為股票型、D 為債券型;查詢使用具名常數 ACTIVE_EQUITY_ETF_CODE_PATTERN。所有範圍內 ETF 均可納入報價,成分股異動只計台灣上市櫃股票。ETF 漲跌是相鄰官方交易日收盤價的價格變動,等權平均不代表資產加權報酬,也不是含息總報酬。

持股比較要求兩個相鄰官方交易日都有成功、非空、筆數吻合的快照。缺前日/當日快照、首次收錄及截斷資料不會視作全部買入或賣出。以股為單位的有效非負數量才計算,缺當日有效收盤價的股票不估金額;不回填舊價格。最近五個交易日的持股增減要求相同 ETF 在六個交易日都有完整快照和有效數量,避免樣本進出被誤認為趨勢。

若新增股票同時伴隨前期未識別 raw code 消失,或退出股票同時伴隨本期未識別 raw code 出現,無法證明的新增/退出會排除,避免把識別變動視為交易。兩期持續存在的未識別列不會阻擋其他股票的真正新增/退出。相同 ETF/日期先依成分股標準識別彙總,資料商 raw code 格式改變不視為買賣。股票金額為 abs(今日股數−前日股數)×今日收盤價。正文每檔精選股票均列出全部有效買方與賣方、逐檔張數及預估金額;雙向時不可省略任一方,並列出淨結果。淨減持股票先列完全部賣方再列買方;淨增持或淨額為零的股票先列完全部買方再列賣方。既有 facts 候選依最大買方、最大賣方、雙向較小側金額最大及新增持股挑選,再依雙方合計補足。正文必須保留候選中的主要增持、減持與最大雙向分歧代表;模板聚焦三檔代表股;不足三檔代表時依買賣合計金額補足,候選不足三檔則全選。

LLM、快取與排程

LLM 輸出 JSON 句子計畫:從候選股選出 3–5 檔,保留 required=true 的代表股;不足 3 檔時全選。預設選三檔,超出三檔的股票必須實際引用其趨勢或完整跨股觀察;每段限一檔,先引用該股的摘要片語,再各引用一次全部買方/賣方片語;雙向操作不能省略任何一方。overview 必須引用程式提供的價格與持股淨額片語,模型僅可用經驗證的中性銜接詞及 ,。、;: 組織順序與段落,不能另寫自由市場敘述;問號、驚嘆號、括號、引號及半形標點不在允許範圍。每個片語間隙會獨立驗證,銜接詞不可跨片語拆開;全部間隙的自由文字合計最多 160 字。最後一個片語之後只允許標點與空白,不接受孤立的段尾銜接詞。相鄰片語必須由允許的標點分隔,除非前一個已驗證片語本身已有允許的句讀。可省略其他候選,並可選用已驗證且具完整個股證據的跨股觀察。數字、股票/ETF 名稱與代號、方向與趨勢都不能在自由文字重寫,未知/遺漏/重複/錯配片語、額外事實文字或不合法 JSON 均觸發模板。相容模型可用單一 Markdown 程式碼區塊包裝完整 JSON,但不接受區塊外敘述。

漲跌概要只敘述非零家數;全部上漲、下跌或持平時統一使用「X 檔主動式股票 ETF 全數上漲/下跌/持平」,不另列其他方向為 0。混合行情也省略零家數,例如只列「上漲 2 檔、持平 1 檔」。LLM 的共用事實片語與固定模板套用相同規則,無報價時仍顯示資料不足。

正文由漲跌概要、持股淨額、少量具名個股重點及一句估算說明組成。持股淨額直接寫「整體淨增持約/整體淨減持約」,不顯示「可比較持股」等內部用語;估算方式統一留在文末。整篇最多選一個有意義的近期連續增減或反轉,併入相關個股;模板優先保留所選股票中最長的有效連續趨勢,連續增減以「最近五個交易日」描述。僅在最近交易日的趨勢方向與當日淨額方向一致時附上趨勢句,方向相反或當日買賣相抵時省略,避免不同 ETF 樣本造成矛盾。正文不輸出缺漏 ETF 清單、固定樣本、六日區間、最近五個交易日的原始明細、排除筆數等診斷資訊。完整診斷資料與未選個股仍保留於 facts。

沿用 config/core.yaml 的 LLM provider 設定;Gemini 模型池啟用時經共用 router 的 active_etf 路由選模。mas.agents.active_etf 可覆寫 provider/model,並設定 temperature、max_tokens、timeout_ms(預設 60 秒)、max_featured_stocks(預設 8)。無效設定/缺少憑證也可產生模板。失敗日誌保留階段、provider/model 識別、例外類型鏈、HTTP 狀態、retry-after 及程式位置;不輸出例外訊息、payload 或含例外內容的 traceback。

報告沿用 public.recap_analysis_cache;LLM 送出紀錄存在 migration 169 新增的 public.active_etf_recap_llm_sends(資料日、階段、輸入 hash、結果、模型),與快取寫入在同一交易。輸入 hash 包含完整計算事實與報告版本;報告版本改變後,同一份 facts 在下一次排程時段送出前是固定模板;同一輸入之後仍命中快取。公開 GET 永遠只讀快取,不為更新文案呼叫 LLM 或寫入資料庫。舊 legacy 格式仍可在 GET/POST 回傳邊界依快取 facts 轉成精簡模板,不回寫快取、不呼叫 LLM;generation_mode、fallback_reason、generated_at 表示原始持久化生成來源,不把讀取投影宣稱為新版 LLM 文案。每個資料日最多兩次排程 LLM 成功送出:

  • 晚間:ETF 補抓 20:05 那輪跑完後送一次;失敗則在 21:30、23:00 補抓後再試,成功即停。三次都失敗就維持固定模板,隔天也不再送。
  • 隔天早上:若晚間已成功,但之後輸入 hash 改變(晚到的持股),在隔天 09:30 補抓後再送一次;成功或失敗都只此一次,之後輸入再改變就維持固定模板。
  • 其他所有重算(每日更新、收盤行情、ETF catalog、補抓預熱、管理 POST force=false)都只產生固定模板,不呼叫 LLM。
  • 送 LLM 失敗時,若同一份輸入已經有 LLM 報告(例如先前的手動送出),保留該報告,只記下失敗紀錄。
  • 帶送出階段的補抓時段若因 ETF 整批流程仍占用鎖而略過,scheduler 記 WARNING。20:05、21:30 略過時由下一個晚間時段接手(23:00 除外);隔天 09:30 那次沒有後續時段,若晚間送出後輸入已改變,該資料日就維持固定模板。

時段設定在 config/core.yaml 的 etf_holdings.retry.cron_windows,以 active_etf_llm_stage: evening|next_morning 標記。每個交易日以 PostgreSQL transaction advisory lock 避免 API 與排程重複呼叫 LLM;寫入 commit 後讀回確認。來源修正、晚到快照或報告版本改變 hash 後,同一天的報告會更新為固定模板。

ETF 每日抓取成功,以及 catchup/gap retry 實際補成功的日期,都會預熱日報;有晚到資料時重新檢查最早補成功日期到最近可產生報告日之間的每個官方交易日,避免前日基準或五個交易日趨勢補齊後仍沿用不完整報告。休市與 15:30 前的日期先解析一次,避免同日重複預熱。單日產生失敗不阻止其他日期補齊或既有日報。公開 latest 端點只讀快取,前端以 trade_date 與 generated_at 顯示日期與更新時間。

Frontend Action Items

綜合頁繼續以 analysis_type=active_etf 讀取並顯示 analysis.headline、analysis.report;request、response 欄位與型別不變,無需遷移。fallback_reason 新增 llm_deferred:當天 20:05 前與晚到資料更新後,頁面會例行顯示固定模板,不是錯誤。每次股票/ETF 名稱附代號,前端可沿用名稱連結。需要完整異動明細時使用 facts.stocks,不能把正文未精選解讀成無異動。coverage、exclusions 是診斷資料,請勿另行拼回報告。

驗證與範例

完整真實來源保存在 investment-agent/backend/tests/fixtures/active_etf/source-2026-09-24.json.gz,manifest 記錄 SHA-256 與全量筆數:30 基金、144 同步紀錄、6,535 持股、1,596 價格,涵蓋六個官方交易日。當日有 30 檔可比較報價、16 檔可比較持股;上漲 21、下跌 8、持平 1;買方估算 1,712,646,500 元、賣方 17,791,852,450 元。

2026-09-24 精簡固定模板範例 由此資料產生。2026-09-29 文案調整範例 使用公開 API 完整快取重播,原文 3,967 字縮為 450 字;快取 fixture 附來源、雜湊及完整筆數紀錄。2026-09-30 固定模板 與 LLM 實際產出 使用同一份完整公開 facts;LLM 範例附實際模型回應與離線重播。測試涵蓋 LLM 成功/失敗、來源缺漏、日期、雙向明細、API 權限與舊契約,以及獨立 PostgreSQL 的原始 SQL、首次/重跑/強制更新、晚到資料、併發、寫入失敗後重試與快取讀回。

PostgreSQL 回放納入 .github/workflows/test.yml 的 schema-parity 工作;須明確設定 ACTIVE_ETF_TEST_DATABASE_URL 與 ACTIVE_ETF_TEST_ALLOW_DISPOSABLE_DB=1,只允許專用測試資料庫名稱,拒絕應用程式資料庫。