跳轉到

AETF Agent:單一主動式 ETF 操作與績效報告

AETF Agent 以單一台灣掛牌主動式股票 ETF 為範圍,將最近 21 個官方交易日的持股快照轉成今日、近 5 日與近 20 日的可驗證事實,再產生三段純正文報告。API 的五個期間詳情欄位保留為機械化明細;閱讀正文使用 analysis.report。它不取代既有的跨 ETF 綜合日報,也不改變原有持股查詢 API。

第一版只使用 Cortex 已有的台灣資料。海外持股、非股票部位、無法穩定識別的標的或缺少一致股數單位的資料會留在 coverage 診斷中;報告只描述已確認範圍,不會把它們當成零,也不會宣稱全基金沒有異動。

API

產生或重用報告

POST /api/etf-holdings/{etf_ticker}/aetf-agent/analyze?date=2026-10-13&force=false
Authorization: Bearer <admin-token>
  • {etf_ticker} 必須是 canonical 台灣 ticker,例如 00981A.TW。
  • date 可省略,預設為台北今日;不得是未來或官方日曆範圍外日期。
  • force=false 時,相同版本與相同 facts hash 會重用逐檔快取。若舊快取是暫時性的模板 fallback,而且本次已有可用 LLM,系統會自動重試。
  • force=true 會重新產生並更新同一 cache key,不會新增重複列。
  • 端點需要管理員權限;LLM 不可用、逾時、錯誤或輸出不合法仍回 HTTP 200 的機械模板。
  • 沒有 request body,date/force 都是 query parameters;不指定日期時省略 date,不要送空字串或字面值 null。
  • POST 同步等待報告生成、持久化並讀回後回 200,不是 202 背景 job。一般公開頁面不要因 GET 404 自動呼叫 POST。

讀取最新報告

GET /api/etf-holdings/{etf_ticker}/aetf-agent/latest?date=2026-10-13
  • 公開、唯讀、只查 aetf_report_cache,不讀持股來源、不呼叫 LLM。
  • 省略 date 時讀取該 ETF 當前報告版本的最新日期;指定 date 時讀取該 requested report date。

Response

兩個端點成功時皆回 AETFReportResponse:

回應沒有額外的 data/success wrapper。holdings_as_of 必定存在但可為 null;generation_mode=llm 時 fallback_reason=null,template 時則有具體原因。llm_disabled 是預設機械模式的正常成功狀態,不是資料讀取失敗。

{
  "agent_type": "aetf",
  "etf": {
    "ticker": "00981A.TW",
    "name": "主動統一台股增長",
    "market": "taiwan"
  },
  "requested_date": "2026-10-13",
  "anchor_date": "2026-10-13",
  "holdings_as_of": "2026-10-13",
  "generated_at": "2026-10-14T02:00:00Z",
  "analysis": {
    "report_version": "aetf-v14",
    "headline": "AETF Agent|主動統一台股增長(00981A.TW)|資料日 2026-10-13",
    "sections": {
      "operation_summary": "…",
      "today": "…",
      "five_day": "…",
      "twenty_day": "…",
      "performance": "…"
    },
    "report": "近期主要持股動向。\n\n跨期部位比較。\n\nETF 市價績效。",
    "generation_mode": "template",
    "fallback_reason": "llm_disabled",
    "facts": {
      "schema_version": "aetf-facts-v2",
      "strategy": {
        "windows": {
          "five_day": {
            "classification": "selective_reallocation",
            "confirmed_increase_count": 12,
            "confirmed_decrease_count": 9
          }
        }
      },
      "windows": {}
    }
  }
}

requested_date、anchor_date 與 holdings_as_of 是三個不同概念。anchor_date 是 requested date 當日或以前、已符合當日資料時間資格的最近官方交易日:只有 requested date 等於台北今天且現在早於共用 DAILY_STOCK_UPDATE_TIME(15:30)時,排除今天;15:30 起納入今天,歷史請求不套今天的時間限制。holdings_as_of 才是該 ETF 最後一個同步成功、非空且列數一致的 snapshot。週末/休市回到最近官方交易日,不額外再退一個 session;requested date 與 anchor 的差異本身不算持股 lag。

錯誤語意依端點分開:

端點 狀態 情況
POST 400 ETF 存在,但不是 enabled 主動式股票 ETF
POST 401 token 無效或過期
POST 403 帳號停權或不是管理員
POST 404 token 對應的會員不存在/已刪除、ETF 不存在、requested date(含)以前沒有有效完整 snapshot,或官方 calendar 在有效 anchor(含)以前本身不足 21 個 session
POST 422 缺少必要 Authorization header、ticker 非 canonical、日期格式錯誤、未來日期或官方日曆範圍外日期
POST 503 會員或管理員權限 schema 尚未就緒
GET 404 尚無當前 report version 的指定日期/最新一筆快取;語法有效但未命中 cache 的未來/日曆外日期也屬此情況
GET 422 ticker 非 canonical,或 date 不是合法日期格式
POST/GET 500 cache payload、資料庫或其他執行錯誤;不偽裝成 LLM fallback

ETF snapshot 歷史與官方 calendar coverage 是兩個獨立條件。新上市基金即使只有 1 個有效 snapshot,只要有效 anchor(含)以前仍可選滿 21 個官方 session,POST 就回 200;其他 20 個 session 的持股缺漏由各窗口的 partial|unavailable 揭露。只有官方 calendar 自身選不滿 21 個 session 才回 404,不會因 ETF snapshot 少而誤判 calendar 不足。GET 為維持 cache-only,不重讀基金主檔、官方日曆或 source,也不重新判斷 ETF 目前是否 enabled/主動式;只有 cache row 自身的 canonical identity、requested date 與 report version 會在回傳前驗證。

端點主動拋出的 domain error 經既有全域 handler 轉為 {"error":{"code":"…","message":"…","details":{}}};request 型別驗證維持 FastAPI {"detail":[…]}。未處理錯誤沿用 Cortex INTERNAL_ERROR envelope,且不回傳內部 exception。

Facts 與計算口徑

官方交易日窗口

視窗 快照數 變化區間 績效基準
今日 2 1 前一官方交易日 close
近 5 日 6 5 5 個官方交易日前 close
近 20 日 21 20 20 個官方交易日前 close

每個窗口的 holdings_coverage 與 performance_coverage 分開計算,狀態為 complete、partial 或 unavailable。缺值是 null,並附 reasons 與 missing_dates,不會用 0 補值。不足 21 個 session 時不會標示為完整 20 日。

持股與績效各自選取官方交易日窗口:持股的 2/6/21 日序列結束於 holdings_as_of,績效的 2/6/21 日序列結束於 official anchor_date。

上述為保留的公開 facts 契約。報告專用 serializer 另將資產範圍與股數品質分開:scope.covers_entire_fund 表示是否涵蓋全基金,periods.*.data_status 表示範圍內股數是否足以判定;只排除現金/非股票/海外部位不使範圍內完整個股降級。若排除項含未識別或海外股票,正文須用「就台股部位而言」限定操作結論;只有現金等非股票列被排除時不要求此句。start_date 為基準日,累計區間為 start_date < daily_changes.date <= end_date;interval_count=1/5/20 依官方交易日,不因缺資料而縮短。缺價不抹除股數證據,估值保留 null。

持股快照只有在同步狀態為 success、fetched_count > 0 且等於實際持久化列數時才是完整快照。最後一個符合條件的 snapshot 決定窗口終點;窗口內缺少中間日仍會保留 missing_dates 並降級,不會跨日拼接成「連續交易」或把未知補成零。

facts.holdings_freshness 提供 {status, anchor_date, holdings_as_of, lag_session_count}。current 表示持股已對齊 official anchor,stale 表示落後,unknown 表示無法驗證;lag 只數 (holdings_as_of, anchor_date] 的官方交易日,不數週末或休市日。落後時機械樣板與通過驗證的 LLM 正文都必須明示持股日期與落後官方交易日數;不設任意 stale 天數門檻。

持股變化

  • 股數判斷只使用兩端均為 quantity_unit=shares、數值有效且識別穩定的標的。
  • 新增與退出要求兩端快照完整;首次收錄、截斷/空白快照、重複身份或名稱代號轉換不會被當成全買/全賣。
  • 只有權重的資料只回報權重變化,不推算股數或金額。
  • 所有有效非零微小變化先寫入 daily_intervals 與 stock_changes;正文 top-N 是之後的呈現選擇,不是計算門檻。
  • estimated_amount = quantity_delta × 同日 raw close。期間總額由逐日正向、負向與淨額分別相加,不使用期末價格回推整段,也不代表真實成交額或申贖資金流。
  • increase_days、decrease_days、streak_* 與 latest_reversal 都由相鄰完整區間計算;缺漏或未變動會中斷連續序列。
  • 5 日窗口是 20 日窗口的子區間;文字可比較短期與較長期淨方向,但不把它們當作兩份獨立證據,也不推論動機或看多信心。

買賣策略 facts

程式從完整逐股變化建立 facts.strategy,每個窗口只使用身分、股數與公司行動證據完整的標的,分類為 selective_reallocation(個別標的雙向調整)、confirmed_accumulation、confirmed_reduction、activity_without_net_direction、confirmed_no_change 或 no_confirmed_direction。同時保留已確認增減持檔數、主要增減持標的、分多日操作、近期反轉、5 日與 20 日方向差異,以及最大持股與近 5 日主要增持是否一致。

這裡的「策略」只指持股快照可觀察到的操作型態;interpretation_boundary=holdings_change_pattern_not_manager_intent,不代表已識別基金經理人的動機、看多/看空信心或未來判斷。持股差仍可能含申購/贖回效果。

公司行動

公司行動 absence 只有在四個 stage dataset 的 completed rebuild run 對相鄰區間都有 complete market coverage 時才成立。若有拆股、股票股利、減資、合併換股等可能改變股數的事件,或 coverage 不完整,raw delta 仍保留,但 action_status 會降級且不能稱為買賣。

持股差也可能包含申購/贖回造成的規模變化;AETF Agent 不宣稱已隔離基金經理人的主動交易。

ETF 績效

績效明確標示為 ETF 市價 raw-close 漲跌:

return_pct = (end_close / baseline_close - 1) × 100%

每個窗口回傳確切 baseline_date、end_date、價格與 coverage;日期來自結束於 official anchor_date 的獨立績效序列,不受舊持股日拖累。adj_close 未被當成 total return;報告不稱 NAV 或含息總報酬。區間若有 ETF 除權息/公司行動,會附 caveat。

機械模板與 LLM 邊界

程式先建立完整 facts。analysis.sections 保留操作總結、今日操作、近 5 日、近 20 日、近期績效五個機械化詳情欄位,供既有 API 消費者使用;analysis.report 輸出持股動向、跨期比較、ETF 績效三個自然段,不含標題。config/core.yaml 的 mas.agents.aetf.llm_enabled 僅在值嚴格為布林 true 時啟用 LLM;缺省、false 或其他型別都使用純機械樣板,預設為 false。設定於程序啟動時讀取,修改後須由既有部署流程 reload/restart 才會套用。啟用時沿用使用者指定 prompt;受支援的期間、股票、數值、方向、績效與必要警語 claim 會綁回既有 facts,動機或績效歸因則直接拒絕。格式或驗證檢查未通過時 fail closed 回同一份 report_data 產生的機械模板,null/缺列不當成零。未設定 provider、逾時或呼叫失敗時也回模板。generation_mode 描述正文的生成來源。

LLM 的 user prompt 由報告專用 core/mas/aetf_report_data.py 組裝,主要三組為 current_holdings、daily_changes、stock_summary。目前持股不刪除近期未異動股;每日保留官方交易日序列,buys/sells 是快照增減而非實際成交;逐股 5/20 日合計直接使用同一份標準化每日股數與估值,不另算一套。完整無異動股有零值合計,已退出股仍保留歷史。semantics 集中定義單位、估值、績效、邊界、重疊與缺值,正常明細不重複警語。

報告中的每日 data_status 反映範圍內股數可判定性;不足時用 all_stocks_unknown 或 unknown_tickers 標記日期/股票,缺價另列例外與金額 null。逐股期間省略 status 即完整;不完整時完整期間的股數、估值、日數欄位全部 null,可確認部分放 confirmed_subintervals。ending_streak 固定為方向/正整數、確認無連續的 none/0,或無法確認的 null。不傳重複 observed 欄位、另一份 windows.stock_changes 或 price_events;公司行動旗標保留 true/false/null 三態,完整事件證據仍在 facts。

system prompt 以使用者 2026-10-16 原文為基礎:三個以空行分隔的自然段、約 250~400 字,原則選 3~5 檔個股,不輸出 JSON、標題、引用編號或 schema。第二段以「近期調整:」開頭;績效段不列日期、收盤價或配息金額,固定以「以上為未還原市價漲跌,非 NAV 或含息總報酬」收尾。萬元/億元與百分比最多兩位小數。比較主要調整依期間淨估值;「轉為加碼」須有每日先後支持,連續操作以 ending_streak 為準;不將每日明細與期間合計重複相加。正文不用「期初/期末」,連續操作寫「截至最新一日」,20 日持股數位置比較才寫「20 個交易日前」。

新版允許在前兩段自然加入最多一至兩句穩定的一般業務背景或個股業務關係,但不能用背景推論本次操作原因。持股、方向、數字與績效仍只能來自輸入;近期新聞/市場現況須有當期來源,未提供時不補充。既有詞彙檢查不等於背景真實性或所有自然語言主張的完整語意驗證。

純機械樣板只在期間 data_status=complete、逐股期間完整且估值可比較時依 net_shares 方向與 estimated_net_amount 規模選主要增減;股數與估值淨方向不一致或缺價時不抹除符號、也不改用股數排名。整體無異動文案另要求至少一檔可分析台股,且所有逐股增減持股數都是明確的零;空股票集合、null 或 partial 期間只說無法判定,已確認的局部活動則保留並明示完整期間方向未知。有至少 2 個交易日、已驗證且截至最新一日的 ending_streak 時只寫連續日數,否則才寫期間累計操作日數;連續方向與期間淨方向不同時會分開陳述,方向轉換仍須有完整每日先後證據。淨零但有雙向活動不稱為無異動。公司行動與市價報酬只寫已確認的期間,新增/退出只取 position_change,不由權重為零推論。

報告版本 aetf-v14 隔離舊版快取;下一次 POST 會產生日數去重、區分確認零與資料未知,並以「截至最新一日」及「20 個交易日前」表達時間位置的正文。5 日連續日數達窗口上限時,改查同股 20 日完整證據;若仍受 5 日或 20 日邊界限制,就保留「近 5 日內」或「近 20 日內」限定。GET 不自動生成,尚無 v14 報告時回 404。這次版本更新不變更 facts、report_data 或公開 API shape。歷史上的 aetf-v13 版本隔離了舊版錯選未收盤日期的快取;v14 保留其 official anchor/holdings anchor 分離、官方交易日 lag、獨立績效窗口與 stale 強制揭露。同一官方交易日以今天為 requested date,在 15:30 前後再次 POST 時,新的來源窗口使 input hash 改變並重算;週末/休市不因跨過此時間而改變窗口。Frontend Action Items:正文使用 analysis.report,不要拼接五個 sections 詳情代替正文;request、response 外層欄位名稱與 error contract 不變。三組結構及缺值語意見 report_data 契約。

服務端檢查非字串/空文、三段格式、600 字元安全上限、已知名稱與代號、日期/數字、萬元/億元換算,以及具名股票與明確期間的淨增減方向(含同句 5/20 日比較與淨零反例)。禁止「已識別持股中」「可驗證範圍」等處理用語及「期初/期末」報告用語;20 個交易日前的持股數比較只綁定近 20 日完整方向證據。不強制反覆輸出申贖或品質統計,缺漏實質影響結論時應用白話說明。這些是詞彙防線,不是自然語言語意證明:不能保證所有數字歸屬、跨句代名詞、隱含動機或績效歸因都被識別;本輪驗證未呼叫外部 LLM,也不構成 provider 文案驗收。ETF/股票名稱均為資料而非指令。以下任一情況退回模板:

狀態 generation_mode fallback_reason
config 關閉(預設) template llm_disabled
LLM 未設定 template llm_unavailable
逾時 template llm_timeout
呼叫失敗 template llm_error
非純正文/段落數/詞彙或必要警語檢查失敗 template invalid_response
合法策略敘述 llm null

資料 coverage 與模型狀態互相獨立;模型 fallback 不會把資料不足改寫成完整。

Cache 與 migration

Migration 167_aetf_report_cache.sql 建立 public.aetf_report_cache。唯一鍵為:

(etf_stock_code, etf_listing_type, report_date, report_version)

其中 report_date 是 requested date;anchor_date 與 holdings_as_of 保留在 response。cache 另存 input_hash、完整 facts input、完整 response payload、模型/provider 與 timestamps。input_hash 由 report version、該次生成採用的 llm_enabled 布林政策與 deterministic facts 計算,所以補齊或修正歷史持股、價格、coverage,或切換生成政策,都會更新同 key 的報告。關閉後的下一次 POST 不會沿用舊 LLM 報告;重新啟用後可由模板升級為 LLM。相同模式與 facts 仍沿用 cache。公開 GET 保持 cache-only,回傳已存報告,不讀 config 或即時重生。

相同 key 的生成使用 PostgreSQL transaction advisory lock;完成後 upsert 並 read-back。取消、LLM 以外的錯誤或寫入失敗會 rollback,之後可重試。不同 ETF identity 互相隔離。

持股同步後自動生成

Scheduler 在每日持股同步、16:30 catalog 流程完成後,對啟用中的主動股票 ETF 逐檔生成單檔報告。即使部分基金抓取失敗,已保存的有效快照仍可用於生成;實際持股日期落後時,維持原本的 holdings_freshness=stale 揭露。單檔來源或生成失敗會記錄 ticker、報告日期及原因,其他基金繼續處理,且不改寫持股同步結果。

補抓流程只有在持股成功提交後才觸發,會從最早補回的交易日更新至最新可用官方交易日,避免較早快照修正後,後續 5/20 日分析仍使用舊快取。每次沿用 force=false、輸入 hash 與逐檔鎖;相同輸入沿用既有報告。沒有新增 cron,公開 GET 仍不會觸發生成。定向/歷史持股 CLI 不額外生成報告。

Scheduler 的台股 OHLC 日常同步、啟動恢復及夜間維護若提交價格變更,會重新檢查變更日及以後已存在的當前版本單檔快取;僅刷新已有的基金/日期,不額外建立歷史報告。各日期與基金的失敗互相隔離,不影響已提交的價格同步。

為避免價格提交與首次報告生成重疊時漏掉尚未提交的快取,生成期間持有共享的 PostgreSQL transaction advisory lock;OHLC 更新後查詢既存快取時先取得對應的獨占鎖,等待在途生成 commit 或 rollback。查詢完成即釋鎖,再逐檔刷新。此協調依現行 READ COMMITTED 隔離層級運作;公開 GET 仍只讀快取。

Scheduler 使用官方目標交易日作為 requested_date,不以 holdings_as_of 或週末執行日取代。管理端補最新快取時也應指定最新可用官方交易日;否則較大的週末 requested date 會在 GET 的日期排序中優先於交易日快取。

相容性與前端接法

  • 這兩個 endpoint、cache table 與 core/mas/aetf_* 都是 additive;既有綜合日報、ETF holdings API 與 MAS 個股 response 不變。
  • 前端先呼叫公開 GET。404 表示尚未生成;管理流程可用 POST 產生。
  • 以 anchor_date 與 holdings_as_of 顯示資料日期;不要單看 requested_date 標示「今日」。
  • 每個窗口分別讀 holdings_coverage 與 performance_coverage。partial 只能呈現已確認觀察,unavailable 顯示暫無法判定。
  • estimated_amount 必須標示為快照差估值,不得標成成交額或申贖流量;績效不得標成 NAV 或含息總報酬。
  • facts.current_holdings 是完整結構化明細;正文只呈現主要個股,最大持股與近期主要增持股是不同概念。
  • analysis.report 是純文字,以空行分成三段;可以使用 white-space: pre-line 或依 \n\n 切段,不需要當作 HTML 插入。
  • GET 404 的畫面顯示「尚無報告」;不得據此推論 ETF 沒有持股或沒有操作。v14 上線後須先由管理流程或持股同步後的自動流程生成 v14 cache 才能讀到新版,不回退拿 v13 當新版。
  • 錯誤解析同時支援 domain/auth 的 error.message 與 request validation 的 detail[];不要只處理單一 envelope。