跳轉到

Cortex Investment Agent API — 前端串接技術文件

Base URL: http://<host>:<port> API Version: v1 Protocol: REST (JSON) + SSE Authentication: Bearer Token (JWT)


目錄

  1. 通用說明
  2. 認證 Auth
  3. 會員 Members 3B. 管理端會員 Admin Members
  4. 訂閱 Subscriptions
  5. 股票資料 Stocks
  6. 技術指標 Indicators
  7. 三大法人 Institutional 7b. 八大行庫/券商分點 Participant Flows
  8. 融資融券 Margin 8B. 台股逐檔當沖統計 Day Trading
  9. 股權分散表 Ownership
  10. 基本面 Fundamentals
  11. 策略訊號 Signals
  12. 每日分析 Daily Analysis
  13. 訊號回測 Signal Backtest
  14. MAS 多代理分析
  15. Agent Payloads
  16. 自選股 Watchlist
  17. 投資組合 Portfolio
  18. 族群 Groups 18B. 統一個股清單 Stock Lists 18C. 單一題材量價分析 Theme Agent
  19. 策略規則 Rules
  20. 個股規則 Stock Rules
  21. 黑天鵝階段 Black Swan
  22. 供應鏈分析 Supply Chain
  23. 更新紀錄 Updates
  24. Health Check
  25. 錯誤處理
  26. Observability — Access Log 26B. Server Metrics
  27. 統一策略 Strategy
  28. 原物料 Commodities
  29. MAS Execution Trace
  30. Data Status 30B. Sync Status
  31. 籌碼面 Recap
  32. 技術面 Recap 32B. 首頁 Market Pulse 32C. 統一 Daily Market 32D. 全市場多面向日報分析
  33. 用戶反饋 Feedback
  34. 管理端反饋 Admin Feedback
  35. 使用者群組 User Groups

1. 通用說明

CORS

Fail-closed allow-list(非全開放)。後端只允許 config/core.yaml 的 api.cors.allow_origins 明確列出的 origin(本機預設 http://localhost:1688 管理後台),並帶 allow_credentials=true。Cortex Web 透過同源 BFF 呼叫 API, 不需要加入此 allow-list。絕不使用 * (* + credentials 不安全)。未列入的 origin 其 preflight 會被回 400 Disallowed CORS origin。

正式部署以環境變數 CORS_ALLOW_ORIGINS(逗號分隔)覆寫為真實前端網域,例如:

CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com

indicator-v2 cutover 503(已移除)

此章節描述的機制已不存在(PR2 範圍裁決,2026-08-19)。

指標資料的產出世代原本由 DB 端的四態 cutover 狀態機控管,狀態不是 active 時所有指標端點回 503。該狀態機連同 write barrier、generation ledger、 manifest 與 restore protocol 已整體移除:V2 是 production 唯一呼叫的引擎, 沒有狀態需要 gate。

前端不會再收到 the indicator-v2 cutover is not active 這個 503。 若你的錯誤處理有針對它的分支,可以移除。

algorithm_id / algorithm_fingerprint 同時是最新快照的公式一致性閣門; fingerprint 不符時 /indicators 不會直接回傳舊值,而是改用 worker thread 重算。歷史 indicators series 會依每列 input_basis 維持 raw 顯示尺度:adjusted 列逐日 factor 投影、raw 列不轉換、null/未知 basis 價格 leaf fail-closed;這不是 已移除的 cutover gate。

認證方式

需要認證的 endpoint 在 header 帶入:

Authorization: Bearer <access_token>

認證相關 endpoints 回傳的 access_token 為 JWT。

速率限制 Rate Limiting

所有 endpoint 皆有每 IP 速率限制(slowapi)。超過限制回 429 Too Many Requests。 兩層限制值可在 config/core.yaml 的 api.rate_limit 調整:

  • default(預設 60/minute):套用於所有路由。
  • strict(預設 10/minute):套用於未認證的一次性濫用面 —— POST /api/auth/login/{provider}、/api/auth/exchange、/api/auth/google/verify-token、 /api/auth/apple/verify-token。 /api/auth/refresh 與 OAuth callback 維持 default 層,避免多裝置/共用 NAT 使用者被誤鎖。

安全標頭 Security Headers

所有回應皆帶 Strict-Transport-Security、X-Content-Type-Options: nosniff、 X-Frame-Options: DENY、Referrer-Policy: no-referrer。

通用 Response 格式

成功:HTTP 200,body 為各 endpoint 定義的 JSON schema。

失敗的 body 形狀取決於是誰拒絕的,不是取決於端點。一、二涵蓋絕大多數端點的錯誤; 三~六是繞過全域 handler 的路徑,各有自己的形狀。

判斷時請以「請求走到哪一層被擋下」為準。其中第四種不是 JSON, 無條件 response.json() 會在那裡拋解析錯誤。 以下皆為 2026-08-19 對 live cortex-api 實際請求的回應。

一、端點自己拒絕(HTTPException,涵蓋絕大多數 4xx/5xx)—— api/main.py 的全域 handler 會重塑成 envelope:

{
  "error": {
    "code": "No data available",
    "message": "No data available",
    "details": {}
  }
}

三個欄位由端點拋出的 HTTPException.detail 決定:

detail 型態 code message details
字串(絕大多數) 該字串 同 code {}
物件(少數端點刻意帶結構) 物件的 code,沒有則 "ERROR" 物件的 message,沒有則整個物件的字串化 整個物件

帶結構的已知端點:POST /api/portfolio/transactions/batch(details.item 指出是批次中哪一筆失敗) 與 /api/mas/* 的輸入序列化錯誤(details.code)。

⚠️ 行為變更(本次):物件型 detail 過去被整個字串化塞進 message,前端拿到的是無法 parse 的 Python repr;現改為放進 details。若你原本在 parse 那串 repr,改讀 details。 字串型 detail 的行為完全未變。

投資端點若所有必要國家 relations 都暫不可用,service 會由專屬 handler 回 HTTP 503,同樣使用 envelope:code 固定為 COUNTRY_RELATIONS_UNAVAILABLE、message 固定為 Investment market data is temporarily unavailable、details 為 {}。

二、FastAPI 參數驗證失敗(型別錯、缺必填、ge/le 越界)—— 不經上述 handler,維持 FastAPI 原生格式:

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["query", "days"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

三、端點自行回傳的 JSONResponse——不經上述任何一條路徑,形狀由該端點自訂。 已知案例:

  • GET /health 的 503:回 {status, timestamp, database, services}, 不是 envelope。原始碼 api/main.py 的 health_check() 明確註明是刻意繞過全域 handler, 以免破壞本檔記載的形狀與 runbook 的 jq 斷言

這類端點以各自章節的契約為準。

四、CORS preflight 被拒——CORSMiddleware 在 router 之前就短路回應, 連 exception handler 都碰不到,回的是純文字,不是 JSON:

HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8

Disallowed CORS origin

⚠️ 這是唯一一種不能用 response.json() 解析的失敗回應。 消費端若無條件 parse JSON,會在這裡拋解析錯誤而非拿到錯誤內容。 觸發條件見上方 CORS 章節(allow_credentials=true 禁用 *,未列入的 origin 一律拒絕)。

五、routing/middleware 層——請求還沒進到任何 endpoint 就被拒絕, 自然也不經全域 handler,維持 Starlette 原生形狀:

{ "detail": "Not Found" }
  • 未知路由:404 {"detail": "Not Found"}
  • method 不符:405 {"detail": "Method Not Allowed"}

六、限流 429——由 slowapi 自己的 handler 回傳,同樣不經全域 handler:

{
  "error": "Rate limit exceeded: 10 per 1 minute"
}

⚠️ 注意 error 在這裡是字串,不是物件。 與第一種的 {"error": {"code", "message", "details"}} 同名不同型——前端若寫 body.error.code 會在限流時炸掉。判斷前先檢查型別。 來源:api/main.py 註冊 slowapi 的 _rate_limit_exceeded_handler, 其實作為 JSONResponse({"error": f"Rate limit exceeded: {exc.detail}"}, status_code=429) (2026-08-19 於執行中的 cortex-api 以 inspect.getsource 讀取安裝版本確認)。

⚠️ 本檔部分端點章節仍把失敗 body 寫成 {"detail": "字串"},那是舊敘述, 與實際不符(除非該端點屬上述第三類)。逐端點核對追蹤於 follow-up; 一般情況以本節為準。 上面各段 JSON 皆為 2026-08-19 對 live cortex-api 實際請求的回應。

日期格式

  • 日期欄位統一使用 YYYY-MM-DD(例如 2024-03-15)
  • 時間戳記使用 ISO 8601(例如 2024-03-15T10:30:00)
  • 黑天鵝階段使用 yyyymmdd 格式
  • 所有 date 參數均由 api/helpers.py 統一驗證,格式錯誤一律回傳 HTTP 422

source_window 查詢參數(v12 — UI hint only)

從 v12(migration 046)起,source_window 不再是 SQL filter,而是 前端 UI 偏好提示:

值 說明
2y 預設值,回傳值寫進 filters_applied.source_window 供前端讀回
all 同上

API 行為: - response 永遠同時回 nested metrics_2y + metrics_all 兩組 dict(每組 8 fields),不論 query param 為何。 - ?source_window=2y|all 只會寫進 response 的 filters_applied,不影響 SQL filter / column select / sort。 - 排序預設用 *_2y,由 config/core.yaml › strategy.api.match_default_window(預設 "2y")決定。query param 不會改變排序。 - 傳入 2y 或 all 以外的值 → HTTP 422(FastAPI Pydantic 自動驗證)。

Per Contract D rule shape(每筆 rule / match response):

{
  "rule_id": "...",
  "strategy_key": "long_d1",
  "scope": "global",
  "metrics_2y": {
    "hit_rate": 0.65, "mean_return": 0.018, "expectancy": 0.012,
    "avg_win": 0.025, "avg_loss": 0.013,
    "sample_count": 87, "trigger_rate": 0.05, "profit_factor": 1.8
  },
  "metrics_all": {
    "hit_rate": 0.61, "mean_return": 0.015, "expectancy": 0.010,
    ...
  }
}

任一窗口可能為 null(AD13: 該窗 0 trigger 或 gate 沒過時,整個 dict 為 null)。 Top-level 不再出現 flat hit_rate / expected_value / mean_return 等欄位;DB 雖是 flat 16 column,API serialize 階段必 pack 成 nested。

下列章節的 endpoint 全部遵循此 contract: §15 Agent Payloads — chip、§18 Groups、§19 Rules、§27 Strategy(不含 Story-first 子節)、 §35 User Groups (rules / matches)。

例外:§27 的 Story-first 策略 子節有自己的統計結構。它的 response 雖同樣出現 metrics_2y / metrics_all 鍵名,但 schema 為 StoryMetrics(event_count / average_return / up_count / …),與此處的 Contract D(hit_rate / expectancy / profit_factor 等 8 欄)無關,且不接受 source_window。詳見該子節。


2. 認證 Auth

Prefix: /api/auth

POST /api/auth/login/{provider}

發起 OAuth 登入流程。後端自行從 config 構建 callback URL,前端不需傳 redirect_uri。

Path Params:

名稱 類型 說明
provider string OAuth 提供者 allow-list:google、apple;其他值(含 Facebook)回傳 400 INVALID_PROVIDER

Request Body(optional):

{
  "platform": "web"  // "web" | "android" | "ios", default "web"
}

不傳 body 時預設 platform = "web"。platform 決定 OAuth callback 完成後 redirect 到哪個前端 URL。

Response:

{
  "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "flow_id": "c37e66d8-9acf-4832-bae9-cbab53f503cf",
  "flow_verifier": "browser_held_random_secret"
}

前端收到後將使用者導向 auth_url,並只在該次登入流程的 client session 暫存 flow_verifier。後端 PostgreSQL 僅保存 state / verifier 的 SHA-256 hash;raw value 不會寫入 DB 或 access log。flow_id 僅供前端關聯 callback,不可取代 verifier。


GET|POST /api/auth/callback/{provider}

OAuth callback — 由 OAuth provider 瀏覽器導向觸發,非前端 API 呼叫。

  • GET:Google 使用 query params(?code=xxx&state=yyy)
  • POST:Apple Sign In 使用 form_post(code + state 在 form body)。Apple 只在使用者第一次授權時 額外附上 user 欄位(JSON 字串:{"name": {"firstName", "lastName"}, "email"});後端只取其中的姓名作為 新會員的 display_name,且不會覆蓋 provider 已提供的名稱。user.email 未經驗證,一律以 id_token 的 email / email_verified 為準。欄位缺席或格式錯誤不影響登入。

Redirect 目標由 login 時指定的 platform 決定:

platform redirect URL
web FRONTEND_URL_WEB
android FRONTEND_URL_ANDROID
ios FRONTEND_URL_IOS

成功時:302 redirect 到對應平台 URL,帶 ?auth_code=xxx&auth_flow=<flow_id>。auth_code 是一次性授權碼;前端應確認 auth_flow 與 login response 的 flow_id 相同,再連同原本保存的 verifier 交換 token。

錯誤時:302 redirect 到對應平台 URL,帶 ?error=xxx&message=yyy。新 identity 若沒有 provider 驗證的 email,會以 VERIFIED_EMAIL_REQUIRED fail closed,不會發行 pending token。Facebook callback 亦只會 redirect error。Pre-decode errors(缺少參數、state 無效)redirect 到 FRONTEND_URL_WEB。

⚠️ 此 endpoint 不回傳 JSON,而是 302 redirect。


POST /api/auth/exchange

用一次性授權碼交換 token。前端從 callback redirect 的 URL 取得 auth_code,在此交換。

Request Body:

{
  "code": "one_time_auth_code_from_callback_redirect",
  "flow_verifier": "browser_held_random_secret_from_login"
}

Response:

{
  "status": "authenticated",
  "access_token": "jwt_token",
  "refresh_token": "refresh_token_string",
  "token_type": "bearer",
  "expires_in": 3600,
  "member": { ... }
}

授權碼為一次性使用,預設 30 秒過期,且必須與 login response 的 flow_verifier 配對。錯誤 verifier 不會消耗授權碼;成功交換、重複使用或過期均以 原子 DB 操作處理,無法跨 worker replay。無效輸入回傳 400 INVALID_CODE。

/api/auth/* response 均帶 Cache-Control: no-store,不得由瀏覽器、proxy 或 CDN 快取。


POST /api/auth/google/verify-token

Mobile app Google 登入。App 透過 Google Sign-In SDK 取得 id_token(Google 簽發的 JWT),後端驗證後回傳 app token。

Request Body:

{
  "id_token": "google_signed_jwt_from_sign_in_sdk"
}

Response (成功):

{
  "status": "authenticated",
  "access_token": "jwt_token",
  "refresh_token": "refresh_token_string",
  "token_type": "bearer",
  "expires_in": 3600,
  "member": { ... }
}

Error Responses:

狀態碼 error code 說明
401 INVALID_ID_TOKEN id_token 無效、過期、或 audience 不符
403 VERIFIED_EMAIL_REQUIRED 新 identity 缺少 Google 驗證的 email
403 MEMBER_SUSPENDED 帳號已停權
422 Validation Error 缺少 id_token 欄位
500 Internal Server Error 伺服器端設定異常(如 JWT_SECRET_KEY 未設定)

此端點與 web OAuth 流程(login → callback → exchange)回傳相同的 response 結構,mobile app 和 web 可共用 token 管理邏輯。


POST /api/auth/apple/verify-token

iOS / macOS 原生 App 的 Sign in with Apple。App 透過 AuthenticationServices(ASAuthorizationAppleIDProvider)取得 identityToken(Apple 簽發的 JWT,audience 為 App Bundle ID),後端以 Apple 公鑰驗證後回傳 app token。 Android 沒有 Apple 原生 SDK,請走 redirect flow(POST /api/auth/login/apple 帶 platform: "android")。

後端接受的 audience 為 APPLE_APP_BUNDLE_ID 與 APPLE_CLIENT_ID(Services ID);設定流程見 Runbook:Sign in with Apple 設定。

Request Body:

{
  "id_token": "apple_signed_identity_token_jwt",
  "nonce": "raw_nonce_generated_by_app",              // optional
  "full_name": {                                       // optional — Apple 只在第一次授權提供
    "given_name": "小明",
    "family_name": "王"
  }
}
欄位 類型 說明
id_token string credential.identityToken;最長 8192 字元
nonce string | null App 產生的原始 nonce,未經雜湊。App 把它的 SHA-256 放進 ASAuthorizationAppleIDRequest.nonce,Apple 會把該雜湊原樣回寫到 token 的 nonce claim;後端會對你送來的原始值做 SHA-256 再比對,用於防重放。請送原始值,不要送雜湊後的值,送錯會得到 401。只要 App 在這次登入有設 nonce,這個欄位就是必填:token 帶有 nonce claim 時,省略此欄位或送 null 一律 401,避免外洩的 identityToken 單獨就能換到 session。App 沒設 nonce、token 也沒有 nonce claim 時可省略。最長 256 字元
full_name object | null credential.fullName 的 givenName / familyName。只有第一次授權 Apple 才會給,App 應在當次就送上;之後登入為 null,不會覆蓋既有 display_name。每個欄位最長 200 字元

Response (成功):

{
  "status": "authenticated",
  "access_token": "jwt_token",
  "refresh_token": "refresh_token_string",
  "token_type": "bearer",
  "expires_in": 3600,
  "member": { ... }
}

Error Responses:

狀態碼 error code 說明
401 INVALID_ID_TOKEN id_token 簽章/issuer 無效、過期、audience 不在設定內、nonce 不符,或 token 帶 nonce claim 但 request 沒附原始 nonce
403 VERIFIED_EMAIL_REQUIRED 新 identity 缺少 Apple 驗證的 email
403 MEMBER_SUSPENDED 帳號已停權
409 EMAIL_OWNERSHIP_CONFLICT email 已屬於無法安全合併的既有帳號(含 private relay 信箱一律不合併)
422 Validation Error 缺少 id_token、或欄位超過長度上限
502 PROVIDER_ERROR 無法取得 Apple 公鑰(https://appleid.apple.com/auth/keys)
500 Internal Server Error 伺服器端設定異常(如 APPLE_APP_BUNDLE_ID 與 APPLE_CLIENT_ID 皆未設定、JWT_SECRET_KEY 未設定)

與 Google 版本相同:Apple 的 email 可能是 @privaterelay.appleid.com 代轉信箱,後端視為合法身份但不會用它做帳號合併。 email_verified 由 Apple 以字串 "true" 或布林值提供,後端統一正規化為布林。


POST /api/auth/complete-registration

相容性保留端點;pending registration 已停用。通過既有 request schema 驗證後固定回傳 410 REGISTRATION_COMPLETION_DISABLED;缺欄位或格式錯誤仍為 422。此端點不解析舊 pending token,也不會建立或連結會員、建立 refresh token或寫入 DB。

Request Body:

{
  "pending_token": "legacy_pending_registration_token",
  "email": "user@example.com"
}

410 Response:

{"error": {"code": "REGISTRATION_COMPLETION_DISABLED", "message": "Registration requires a provider-verified email"}}

Email OTP auth — removed

Email + password / OTP authentication (/api/auth/email/*) and the development OTP-lookup endpoint (/api/dev/email-otp/latest) have been removed. Authentication is OAuth-only (Google / Apple — see /api/auth/login/{provider} above). These routes now return 404.


POST /api/auth/refresh

刷新 access token。

Request Body:

{
  "refresh_token": "current_refresh_token"
}

Response:

{
  "access_token": "new_jwt_token",
  "refresh_token": "new_refresh_token",
  "token_type": "bearer"
}

POST /api/auth/logout

單一裝置登出;撤銷 request 中 refresh token 所屬的整個 rotation family,避免並行 refresh 產生的後繼 token 逃逸。

Request Body:

{
  "refresh_token": "refresh_token_to_revoke"
}

POST /api/auth/logout-all

登出所有裝置(撤銷該用戶所有 refresh token)。需認證。


GET /api/auth/me

取得當前用戶資料。需認證。


3. 會員 Members

Prefix: /api/members — 需認證

GET /api/members/me

取得會員個人資料。


PUT /api/members/me

更新會員資料。

Request Body:

{
  "display_name": "新名稱",          // optional, string
  "avatar_url": "https://..."        // optional, string
}

GET /api/members/me/providers

列出已綁定的 OAuth 提供者。既有資料若含 legacy Facebook identity,仍可能出現在清單中。


POST /api/members/me/providers/{provider}

綁定新的 OAuth 提供者。新綁定 allow-list 僅接受 Google、Apple;Facebook 或其他 provider 回 400 INVALID_PROVIDER。

Request Body:

{
  "code": "authorization_code",
  "redirect_uri": "https://your-app.com/callback"
}

DELETE /api/members/me/providers/{provider}

解除綁定 OAuth 提供者;可用於移除既有 legacy Facebook identity。


3B. 管理端會員 Admin Members

Prefix: /api/admin/members — 需 Bearer token 且 members.is_admin = true

GET /api/admin/members

列出會員,包含 OAuth provider、email login 狀態、目前 tier 與 private relay email 訊號。

Query Params:

欄位 類型 必填 預設 說明
page int no 1 頁碼,最小 1
page_size int no 20 每頁筆數,1-100
q string no null 搜尋 email、display name、member id
status string no null active / suspended / deleted
provider string no null google / apple / email
is_admin bool no null 過濾 admin flag
email_verified bool no null 過濾 email credential 是否已驗證

Response 200:

{
  "total": 1,
  "page": 1,
  "page_size": 20,
  "items": [
    {
      "id": "member-id",
      "email": "user@example.com",
      "display_name": "User",
      "avatar_url": null,
      "status": "active",
      "is_admin": false,
      "created_at": "2026-06-11T00:00:00Z",
      "updated_at": "2026-06-11T00:00:00Z",
      "last_login_at": null,
      "tier": {"name": "free", "display_name": "Free", "level": 0},
      "providers": ["google", "email"],
      "email_login": {
        "enabled": true,
        "email_verified": true,
        "otp_required": true,
        "password_updated_at": "2026-06-11T00:00:00Z",
        "locked_until": null
      },
      "has_private_relay_email": false
    }
  ]
}

GET /api/admin/members/identity-lookup

用 provider identity 或 email credential 查找會員。

Query Params:

欄位 類型 必填 說明
provider string yes google / apple / email
provider_user_id string conditional provider=google/apple 時必填
email string conditional provider=email 時必填

Response 200: 單筆 AdminMemberSummary,欄位同 list item。


GET /api/admin/members/{member_id}

取得單一會員管理詳情。

Path Params: member_id 必須是 UUID;格式錯誤會回 422。

Response 200: list item 欄位加上:

欄位 類型 說明
provider_records array OAuth provider 詳細紀錄
current_subscription object/null active/trialing subscription
subscription_history array 最近 20 筆 subscription
recent_auth_events array 最近 20 筆 auth audit logs

PATCH /api/admin/members/{member_id}/status

更新會員狀態。member_id 必須是 UUID;格式錯誤會回 422。設為 suspended 或 deleted 時會撤銷該會員未撤銷的 refresh tokens。後端會阻擋管理員停用/刪除自己的帳號,也會阻擋停用/刪除最後一位 active admin。

Request Body:

欄位 類型 必填 說明
status string yes active / suspended / deleted
reason string no 管理原因,寫入 audit log

Response 200:

{
  "id": "member-id",
  "status": "suspended",
  "revoked_refresh_token_count": 2
}

PATCH /api/admin/members/{member_id}/admin-flag

授予或移除會員 admin flag。member_id 必須是 UUID;格式錯誤會回 422。後端會阻擋移除自己的 admin 權限與移除最後一位 admin。

Request Body:

欄位 類型 必填 說明
is_admin bool yes 目標 admin flag
reason string no 管理原因,寫入 audit log

Response 200:

{
  "id": "member-id",
  "is_admin": true
}

PATCH /api/admin/members/{member_id}/tier

相容性 endpoint:由管理員立即設定會員的無期限 manual entitlement。member_id 必須是 UUID;格式錯誤會回 422。目標 tier 必須存在且 is_active = true,保留的 admin permission set 不可指派。操作會同步 members.current_tier_id 相容 cache 並寫入 audit log,但不會建立或修改 subscription/付款紀錄;目前有效 tier 相同、沒有到期日且沒有未來排程時回 changed=false 且不重複寫 audit。若目前 tier 相同但有到期或未來排程,仍會建立新的無期限指派並取代後續排程。

Request Body:

欄位 類型 必填 Nullable 預設 說明
tier string yes no — active tier 名稱;去空白並轉小寫,例如 free、advance、premium
reason string yes no — 管理原因;去除前後空白後 1-500 字元,寫入 audit log

Response 200:

{
  "id": "member-id",
  "previous_tier": "free",
  "tier": "advance",
  "changed": true
}

Errors:

HTTP Code 說明
400 INVALID_MEMBERSHIP_TIER tier 不存在或已停用
401 auth error Bearer token 無效或過期
403 ADMIN_REQUIRED 呼叫者不是 admin
404 MEMBER_NOT_FOUND 會員不存在
422 FastAPI validation UUID、必填欄位或欄位長度不合法

GET /api/admin/members/investment-overview

分頁列出會員身份、最相關的 membership 排程與投資資料筆數。只計算 watchlist、持股與交易數量,不在 list endpoint 執行完整市場估值。holding_count 與 portfolio API 共用 closure-valid 語意;未完整配對的 sell 不會扣減持股。

Query Params: page、page_size、q、status、tier、membership_state(free / active / scheduled / expired)。

{
  "total": 1,
  "page": 1,
  "page_size": 20,
  "items": [{
    "member_id": "member-id",
    "email": "user@example.com",
    "display_name": "User",
    "status": "active",
    "membership": {
      "tier": "premium",
      "state": "scheduled",
      "source": "manual",
      "set_at": "2026-07-22T00:00:00Z",
      "starts_at": "2026-08-01T00:00:00+08:00",
      "ends_at": "2027-08-01T00:00:00+08:00"
    },
    "investment_summary": {
      "watchlist_group_count": 3,
      "watchlist_item_count": 20,
      "holding_count": 5,
      "transaction_count": 120,
      "last_transaction_at": "2026-07-18T10:00:00Z"
    }
  }]
}

GET /api/admin/members/{member_id}/investment-detail

一次查詢一個 section:watchlists、holdings 或 transactions。共同 response 為 {member_id, section, total, page, page_size, items};page_size 預設 50、最大 200。

  • watchlists 是 group/item flat rows;空 group 仍回一列且 item_id=null。
  • holdings 欄位與 GET /api/portfolio/holdings 相同。
  • transactions 欄位與 GET /api/portfolio/transactions 相同,包含 day-trade 與 unmatched-sell warning。
  • 會員不存在回 404 MEMBER_NOT_FOUND;非法 UUID、section 或分頁回 422。

GET /api/admin/members/{member_id}/transactions/backup

管理員下載指定會員的完整交易帳本 JSON。回應為 application/json 附件並帶 Cache-Control: no-store。匯出在資料表共享鎖內讀取一致快照;目前可用 market 全部列於 markets,空帳本仍回傳合法備份。

匯出保留符合檔案欄位 schema 的資料庫現況並限制檔案大小;不套用還原時的 交易關聯不變量。因此歷史帳本仍可下載保存,但 preview 可能因關聯不符而拒絕還原。 每次操作都透過共用市場 manifest inspector 重新確認資料表集合,不使用 TTL 快取; 任一市場僅存在 transactions/closures 其中一張表時回 503,避免靜默漏匯。

備份頂層欄位:

欄位 型別 說明
format_version 1 檔案格式版本。
schema_version "portfolio-transaction-backup.v1" 欄位 schema 版本。
exported_at ISO 8601 string UTC 匯出時間。
source_member object {id,email,display_name};不含憑證或 token。
markets ("taiwan"|"us")[] 匯出時實際存在的完整市場 relation set。
brokers array 完整券商 persisted rows。
transactions array 完整交易 persisted rows,另加 country。
closures array 完整 lot closure persisted rows,另加 country。
content_sha256 64-char lowercase hex 除本欄位外 canonical JSON 的 SHA-256。

brokers[] 必含 id,member_id,name,fee_rate,fee_discount,note,is_default,created_at,updated_at。 transactions[] 必含 country,id,member_id,stock_code,action,sell_type,price_per_share,quantity,fee,tax,realized_pnl,broker_id,transaction_date,note,created_at,updated_at,day_trade_pair_id,day_trade_direction,listing_type。 closures[] 必含 country,id,sell_transaction_id,buy_transaction_id,member_id,stock_code,_sell_action,_buy_action,qty,created_at,is_day_trade,listing_type。 所有 numeric/decimal 欄位以資料庫 fixed scale 十進位字串保存,integer 數量仍為 integer;UUID 為 string;nullable 欄位保留 null。transaction_date 是不含 offset 的 canonical ISO 8601,persisted audit timestamp 與 exported_at 是 canonical UTC ISO 8601。

POST /api/admin/members/{member_id}/transactions/restore/preview

Request:{"backup": <上述完整備份>}。檔案 canonical JSON 上限為 20 MiB。 後端驗證版本、checksum、來源會員、market、所有 ID/broker/transaction/closure/day-trade 關聯、PostgreSQL 欄位長度/numeric precision/integer range/timestamp shape、股票 master row、跨會員 ID collision 與跨會員 broker reference。成功回:

{
  "source_member": {"id": "...", "email": "...", "display_name": null},
  "target_member": {"id": "...", "email": "...", "display_name": null},
  "exported_at": "2026-09-23T08:00:00+00:00",
  "format_version": 1,
  "incoming": {"brokers": 1, "transactions": 2, "closures": 1, "day_trade_pairs": 0, "actions": {"buy": 1, "sell": 1, "stock_dividend": 0, "cash_dividend": 0}, "date_from": "2026-01-01T00:00:00", "date_to": "2026-01-02T00:00:00"},
  "current": {"brokers": 0, "transactions": 0, "closures": 0, "day_trade_pairs": 0, "actions": {"buy": 0, "sell": 0, "stock_dividend": 0, "cash_dividend": 0}, "date_from": null, "date_to": null},
  "expected_state_sha256": "<64-char hex>",
  "warnings": ["重建會覆蓋此會員目前的券商、交易與 lot closure 資料"]
}

POST /api/admin/members/{member_id}/transactions/restore

Request:backup、preview 回傳的 expected_state_sha256、以及與 path 完全相同的 confirm_member_id。後端取得資料表寫入鎖的等待上限為 5 秒;鎖持有至交易結束,以阻擋其他交易寫入。後端在單一 DB transaction 內驗證 stale state、刪除目標會員的 closures/transactions、依原 ID 新增或更新券商並僅刪除備份未包含的券商,再按原 ID/時間/精度重建交易與 closures, 最後讀回比對 state SHA-256。相同備份重送回 status: "unchanged"; 不進行 FIFO replay。

若環境仍保留 legacy public.transactions,原 ID 券商更新會保留該表的 broker 外鍵。 備份未包含的券商仍會被刪除,其外部 ON DELETE SET NULL 參照(含 legacy 表)會被 清為 null;legacy 交易表不屬於本版備份範圍,現行交易資料以 country schema 為準。

成功回 {"status":"restored|unchanged","member_id":"...","counts":<summary>,"state_sha256":"..."}。 錯誤 code:PORTFOLIO_BACKUP_INVALID、PORTFOLIO_BACKUP_MEMBER_MISMATCH、 PORTFOLIO_BACKUP_TOO_LARGE(422),PORTFOLIO_BACKUP_STALE、 PORTFOLIO_BACKUP_CONFLICT、PORTFOLIO_BACKUP_BUSY(409;資料庫暫時不可用時 BUSY 可為 503),PORTFOLIO_BACKUP_MARKET_UNAVAILABLE(422/503),以及 MEMBER_NOT_FOUND(404)。 所有三個 endpoint 均需管理員;access log 只留 method/path/status/bytes/timing,不保存 request 或 response body。成功 restore 的 auth audit event 只保存 actor、target、status、counts 與 digest。

非法 path UUID 使用 FastAPI 的 detail 驗證錯誤清單;body 驗證錯誤使用 PORTFOLIO_BACKUP_INVALID envelope,訊息為「備份或重建請求格式無效」,不回顯帳本內容。 完整前端契約與操作範例見 會員交易備份交接。

PUT /api/admin/members/{member_id}/membership

從 starts_at 起取代該會員的 manual/legacy 排程。時間區間採左閉右開;未來指派在開始前維持原 tier,到期後若沒有其他有效 entitlement 立即回退 free,不需 scheduler。

{
  "tier": "premium",
  "starts_at": "2026-08-01T00:00:00+08:00",
  "ends_at": "2027-08-01T00:00:00+08:00",
  "reason": "年度 VIP 方案"
}

starts_at 必填且必須含 timezone;ends_at 可為 null,若存在必須晚於起始時間。相同 member/tier/starts/ends 的重複 PUT 回 changed=false,不新增 entitlement 或 audit。Response 同時回 previous_tier、effective_tier、scheduled_tier、state、日期與 entitlement_id。

GET /api/admin/memberships

列出每位會員的有效與已指派 tier。沒有 entitlement 的會員仍會出現,effective_tier=free、assigned_tier=null、state=free。

Query Params: page、page_size、q、status、tier、membership_state、expires_before(timezone-aware datetime)。每筆包含 member identity、effective_tier、assigned_tier、state、source、set_at、starts_at、ends_at、created_by、reason。

API 存取矩陣(授權總則)

API 存取矩陣是「每支 API(method + path)× 等級(含未登入的訪客 visitor)」的可用/不可用設定,由管理員在 Cortex Management 維護,目的是讓會員權限只有一套來源。本版(PR-A1)只出貨觀察模式,矩陣尚未生效:閘門(auth/api_access.py,ENFORCEMENT_ENABLED = False)只解析每個請求對應的路由並記 log,不拒絕任何請求;目前實際擋請求的仍是既有機制(登入、ai_analysis、realtime_quote、watchlist 額度)。強制執行與寫入 API 於 PR-A2 上線。

路由四類(PR-A1 凍結時 api.main.app 共 301 個 operation,62 + 18 + 2 + 219 = 301;之後新增/移除的 managed 路由以 UNSEEDED_ROUTE_KEYS/REMOVED_ROUTE_KEYS 為準,見下文)

類別 數量 範圍 是否進矩陣
admin 62 dependency 含 require_admin 的後台 API 否;維持管理員專用
auth_infra 18 /api/auth/*(11)、/api/members/me*(5)、/ 與 /health(2) 否;登入與帳號基礎 API 不受矩陣影響
internal 2 /api/internal/*:以共享服務 token 驗證的機器對機器接收端點(題材轉移) 否;非會員 API
managed 219 其餘所有 API 是

資料表(migration 170_api_access_matrix.sql)

資料表 內容
api_access_rules 主鍵 (method, path, tier_id),欄位 allowed(true/false)。path 為去掉 Starlette converter 後的完整路徑(例如 {rule_id:path} 存成 {rule_id})。沒有列 = 未設定。
membership_tier_quotas 主鍵 (tier_id, quota_key),limit_value >= -1(-1 表示無限制)。quota_key 目前為 max_watchlists、max_watchlist_items。種子取自各等級 features 的同名 key,值必須是 JSON 整數(-1 或 >= 0、int4 範圍內):JSON 字串(如 "3")、小數、null、超出 int4 範圍或小於 -1 的值會讓 migration 170 整支中止。

membership_tiers 新增保留列 visitor(level -1、is_active=false,代表未登入訪客)。保留名稱為 admin 與 visitor(services/membership_service.py 的 RESERVED_TIER_NAMES),不可指派給會員;admin 恆可用、不產生矩陣列。因此 GET /api/admin/membership-tiers?include_inactive=true 在套用 migration 170 後會多回一筆 visitor(reserved=true、features={})。

初始種子(重現矩陣上線前的行為)。 219 支 managed 路由在 migration 170 前的舊分類記在 tests/data/api_access_golden_routes.json(PUBLIC 151 / LOGIN 58 / PAID 3 / REALTIME 7),由 scripts/generate_api_access_golden.py 產生,migration 的 VALUES 清單由同一支腳本的 --sql-values 輸出,不可手改。每個分類依 live membership_tiers.features 對 visitor 與每個 active 的非 admin 等級推導:

舊分類 種子結果
PUBLIC 所有等級(含 visitor)可用
LOGIN 除 visitor 外所有等級可用
PAID visitor 不可用;其餘等級依該等級 features.ai_analysis 是否為 true
REALTIME visitor 不可用;其餘等級依該等級 features.realtime_quote 是否為 true(7 支即時報價/盤中 K 端點)

migration 不覆寫已存在的列(管理員改過的值重跑不會被蓋掉;但被刪除回「未設定」的列重跑會重新種入);除 visitor 與 admin 外,inactive 的等級不產生列。

預定契約(PR-A2,尚未生效)。 強制執行後,閘門在每個 managed 請求查矩陣:admin 一律放行;訪客被拒回 401 LOGIN_REQUIRED;已登入會員被拒回 403 FEATURE_REQUIRED;未設定視同不可用。以下兩項已由使用者核可(2026-10-21),是 PR-A2 將上線的既定行為:

  • 訪客被拒一律回 401 LOGIN_REQUIRED,因此缺 Authorization header 打需登入的端點會由 422 變 401。
  • INVALID_TOKEN/TOKEN_EXPIRED 的 token 打訪客可用的端點時當訪客處理;其他驗證錯誤(停權 403、不存在 404、設定 503)照原樣回傳。

寫入 API 同在 A2 上線:

  • PUT /api/admin/api-access:{"changes": [{"method", "path", "tier", "allowed": true|false|null}], "reason"};allowed=null 刪除規則列(回到未設定)。
  • PUT /api/admin/membership-tiers/{tier_name}/quotas:{"quotas": {"max_watchlists": <int>, "max_watchlist_items": <int>}, "expected_updated_at": "<ISO 8601 含 offset,取自該等級 GET quotas 的 updated_at>", "reason": "<1-500 字元>"}。所有額度 key 都必須出現,-1 表示無限制;版本過期回 409 MEMBERSHIP_TIER_VERSION_CONFLICT(樂觀鎖模式同權限 PUT);保留等級(admin、visitor)回 400 INVALID_MEMBERSHIP_TIER。

完整契約見 API 存取矩陣管理交接。

新增 API 的規則(守門測試已生效)。 新增一支 managed API 只有一條路:把它的 key("METHOD /path")列入 tests/test_api_access_seed_coverage.py 的 UNSEEDED_ROUTE_KEYS,否則該測試在 CI 變紅。這類路由沒有種子列,強制執行後對所有非 admin 呼叫者一律不可用,Management 顯示「未設定」;需由管理員在矩陣頁開通。

移除 managed API 時,把它的 key 列入同檔的 REMOVED_ROUTE_KEYS;它在 api_access_rules 的列會保留,出現在 GET /api/admin/api-access 的 orphans[],直到 PR-B 清理。

tests/data/api_access_golden_routes.json 是 PR-A1 凍結的快照,不可重新產生或手改;scripts/generate_api_access_golden.py --check 只檢查快照內、app 仍提供的 managed 路由分類沒有改變。

GET /api/admin/api-access

回傳所有 managed API(PR-A1 凍結時 219 支)與每個等級目前儲存的規則,供 Cortex Management 的矩陣頁使用。本版只讀;寫入於 PR-A2。

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

Query Params: 無。

Response 欄位:

欄位 型別 說明
tiers[] array 矩陣欄位:visitor 與所有非 admin 等級(含 inactive),依 (level, name) 排序;admin 不列出。
tiers[].name / display_name string 等級代號/顯示名稱。
tiers[].level integer 等級排序值(visitor 為 -1)。
tiers[].is_active boolean 是否為 active 等級(visitor 為 false)。
tiers[].reserved boolean 是否為保留名稱(visitor 為 true)。
endpoints[] array managed 路由,依 key 排序。
endpoints[].key string "METHOD /path",與規則列的 (method, path) 一一對應。
endpoints[].method / path string HTTP method(大寫)與去掉 converter 後的完整路徑。
endpoints[].tags string[] OpenAPI tags;前端可用 tags[0] 分組。
endpoints[].summary string | null 路由摘要,可能為 null。
endpoints[].requires_member boolean true 表示此 API 結構上必須是登入會員(訪客欄不應可設為可用)。
endpoints[].conditional_login boolean true 表示未登入也能呼叫、但登入與否行為不同(目前僅 GET /api/stock-lists/items)。
endpoints[].rules object {<tier name>: true|false|null}:true 可用、false 不可用、null 沒有規則列(未設定)。key 集合等於 tiers[].name。
orphans[] array 資料庫有規則列、但對應路由已不是 managed 路由(或等級為 admin)的列,供清理:{method, path, tier, allowed}。

Response 範例(節錄:endpoints 實際有 219 筆,此處只列前 2 筆;以下為對本版程式實際呼叫後的回應,資料庫是測試用 SQLite,僅有 visitor/free/advance 三個等級,且只為 DELETE /api/portfolio/day-trades/{pair_id} 對 free 設了一列 true,不是 production 資料):

{
  "tiers": [
    {"name": "visitor", "display_name": "Visitor", "level": -1, "is_active": false, "reserved": true},
    {"name": "free", "display_name": "Free", "level": 0, "is_active": true, "reserved": false},
    {"name": "advance", "display_name": "Advance", "level": 1, "is_active": true, "reserved": false}
  ],
  "endpoints": [
    {
      "key": "DELETE /api/portfolio/day-trades/{pair_id}",
      "method": "DELETE",
      "path": "/api/portfolio/day-trades/{pair_id}",
      "tags": ["portfolio"],
      "summary": null,
      "requires_member": true,
      "conditional_login": false,
      "rules": {"visitor": null, "free": true, "advance": null}
    },
    {
      "key": "DELETE /api/portfolio/transactions/batch",
      "method": "DELETE",
      "path": "/api/portfolio/transactions/batch",
      "tags": ["portfolio"],
      "summary": null,
      "requires_member": true,
      "conditional_login": false,
      "rules": {"visitor": null, "free": null, "advance": null}
    }
  ],
  "orphans": []
}

錯誤:

HTTP code 說明
401 INVALID_TOKEN/TOKEN_EXPIRED token 無效或過期。
403 ADMIN_REQUIRED 非 admin。
404 — 此 endpoint 不會回 404。

GET /api/admin/membership-tiers/{tier_name}/quotas

回傳單一等級儲存的額度。本版只讀;寫入於 PR-A2,額度的實際執行(watchlist 上限)本版仍讀 features。PR-A1 期間儲存的額度可能落後 features(migration 170 只在套用當下複製一次,之後管理員仍可經舊權限 PUT 修改 features),watchlist 上限在 A2 之前仍以 features 執行。

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

Path Params:

參數 型別 說明
tier_name string 等級代號;前後空白會被去除並轉小寫。

Response 欄位:

欄位 型別 說明
tier string 等級代號。
quotas object {max_watchlists, max_watchlist_items} 各為 integer(-1 無限制)或 null(該等級沒有額度列)。
updated_at ISO 8601 string membership_tiers.updated_at(UTC),PR-A2 的 PUT 以它做 expected_updated_at 樂觀鎖。

Response 範例(對本版程式實際呼叫,測試用 SQLite 資料,非 production):

{
  "tier": "free",
  "quotas": {"max_watchlists": 3, "max_watchlist_items": 20},
  "updated_at": "2026-07-22T00:00:00Z"
}

錯誤:

HTTP code 說明
401 INVALID_TOKEN/TOKEN_EXPIRED token 無效或過期。
403 ADMIN_REQUIRED 非 admin。
404 MEMBERSHIP_TIER_NOT_FOUND 等級不存在。
503 MEMBERSHIP_CONFIGURATION_INVALID 該等級的 updated_at 缺失(設定錯誤)。

GET /api/admin/membership-tiers

列出可管理的 tier permission sets。預設只回 active tiers;include_inactive=true 會包含保留的 inactive admin permission set。每筆包含 features、rate_limits、updated_at 與 reserved。

PUT /api/admin/membership-tiers/{tier_name}/permissions

完整取代 tier 的 permission set,並以 expected_updated_at 做 optimistic concurrency。未知/缺少 key、bool 冒充 int 或非法負數回 422;stale version 回 409 MEMBERSHIP_TIER_VERSION_CONFLICT;相同內容回 changed=false 且不重複 audit。

{
  "features": {
    "ai_analysis": true,
    "portfolio_export": true,
    "realtime_quote": false,
    "max_watchlists": 10,
    "max_watchlist_items": 100
  },
  "rate_limits": {
    "requests_per_minute": 120,
    "daily_analyses": 50
  },
  "expected_updated_at": "2026-07-22T00:00:00Z",
  "reason": "調整 Advance 權限"
}

max_watchlists、max_watchlist_items 的 -1 表示無限制;其他值需 >= 0。max_watchlist_items 是單一會員跨所有 watchlist groups 的項目總上限,不是每個 group 各自的上限。requests_per_minute 為 -1 或 >= 1;daily_analyses 為 -1 或 >= 0。本版實際 enforce ai_analysis、realtime_quote(7 個即時報價/盤中 K 端點,見 /api/stocks/{ticker}/realtime-quote 段的「即時報價權限」)與兩個 watchlist limits;動態 rate-limit counter 與 portfolio export endpoint 不在本版範圍。

會員管理部署順序(migration 110)

會員授權讀取採 fail-closed。正式部署必須先在 live PostgreSQL 套用 backend/database/migrations/110_member_entitlements.sql,確認 member_entitlements 與相關 indexes 存在後,才部署/重啟 API 與 scheduler。若程式部署需要回滾,只回滾 application image;一旦 entitlement 已開始寫入,不可向下刪除 migration 110 的資料表或 indexes。

即時報價權限部署順序(migration 168)

realtime_quote 由 backend/database/migrations/168_realtime_quote_tier_feature.sql 寫入:admin permission set 為 true,其他所有 tier 為 false;已經有此 key 的 tier 不覆寫。部署順序為:先在 live PostgreSQL 套用 migration 168,再部署 API;Cortex Management 的權限頁需在 PUT 時帶上 realtime_quote(原樣送回 GET 取得的完整 features 即可)。程式先上而 migration 未套時,即時端點對所有人(含 admin)fail-closed 回 403;migration 先套而程式未上時,舊程式的 PUT 會因未知 key 回 422。回滾:先回滾程式,再執行 UPDATE membership_tiers SET features = features - 'realtime_quote', updated_at = NOW();。

GET /api/admin/access-logs?member_id={member_id}

會員 API 呼叫紀錄沿用既有 access-log endpoint,不另建重複 API。可搭配時間、method、path、status、request ID、limit/offset;request/response body 仍遵循既有遮罩規則。


4. 訂閱 Subscriptions

Prefix: /api/subscriptions

GET /api/subscriptions/tiers

列出所有會員等級。不需認證。


GET /api/subscriptions/plans

列出所有訂閱方案。不需認證。


GET /api/subscriptions/current

取得當前訂閱狀態。需認證。


GET /api/subscriptions/history

取得訂閱歷史。需認證。


POST /api/subscriptions

相容性保留端點。需認證;已登入且通過既有 request schema 驗證後固定回傳 403 SUBSCRIPTION_ACTIVATION_DISABLED,缺失或格式錯誤的 plan_id 仍為 422。目前沒有付款提供者可驗證交易,因此此端點不查詢方案、不建立訂閱、不更新會員等級,也不 commit DB transaction。

Request Body:

{
  "plan_id": 1
}

403 Response:

{"error": {"code": "SUBSCRIPTION_ACTIVATION_DISABLED", "message": "Direct subscription activation is disabled"}}

PUT /api/subscriptions/{subscription_id}/cancel

取消訂閱。需認證。


5. 股票資料 Stocks

Prefix: /api/stocks

GET /api/stocks/search

搜尋股票。

Query Params:

名稱 類型 必填 預設 說明
q string 是 — 搜尋關鍵字(代號或名稱)
market string 否 — 市場篩選(taiwan, us, japan)
limit int 否 20 回傳筆數(1-100)

比對 ticker、name、english_name 三欄(子字串、不分大小寫)。只回有名稱(name IS NOT NULL)的標的——無名稱的列是資料匯入殘留的佔位列,其代號與真實代號有子字串重疊,曾使整個查詢回空結果(#1199)。已下市標的(is_active = false)若有名稱仍會出現在結果中。

Response (422): market 不是有效值時回傳 validation error。

Response (200):

rows 與 results 內容相同,都是去重後的 MarketSurveillanceEventResponse objects。

{
  "results": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "english_name": "TSMC",
      "market": "taiwan",
      "industry": "半導體業",
      "security_type": "common",
      "surveillance_status": "normal"
    }
  ],
  "total": 1
}

surveillance_status 代表台股官方監管狀態,值為 normal、attention、disposition。非台股或查無監管事件時回傳 normal。

security_type(issue #1282)為證券型別單欄分類,15 值平面枚舉:common/preferred/tdr/index/etn/reit/etf/ etf_active/etf_balanced/etf_bond/etf_active_bond/etf_leveraged/etf_inverse/etf_futures/ etf_foreign_ccy。查主動型 ETF:security_type IN ('etf_active','etf_active_bond');查債券型 ETF: security_type IN ('etf_bond','etf_active_bond');查全部 ETF:security_type LIKE 'etf%'。非台股標的預設 common。 etn 為指數投資證券(Exchange Traded Note,發行人無擔保債券,追蹤指數但非基金),02 開頭六碼/五碼+字母尾碼(如 02001L 槓桿型 ETN)與 etf_leveraged/etf_inverse 共用尾碼字母但非同一類,故獨立分類值(2026-08-08 live 盤查發現)。 reit 為不動產投資信託受益證券(Real Estate Investment Trust),01 開頭三碼+字母尾碼(如 01001T 土銀富邦R1), 與 etf_balanced 共用尾碼字母 T 但非同一類,亦非普通股(2026-08-08 使用者核可加入)。


GET /api/stocks/{ticker}

取得單一股票資訊。

Response — StockInfo:

{
  "ticker": "2330.TW",
  "name": "台積電",
  "english_name": "TSMC",
  "market": "taiwan",
  "industry": "半導體業",
  "sector": "資訊科技",
  "security_type": "common",
  "current_price": 850.0,
  "price_change": 15.0,
  "price_change_pct": 1.8,
  "volume": 25000000,
  "last_updated": "2024-03-15",
  "etfs": [
    { "etf_ticker": "0050.TW", "etf_name": "元大台灣50", "weight_pct": 45.2 }
  ],
  "themes": [
    { "theme": "AI 伺服器", "ticker": "2330", "company_name": "台積電", "role": "upstream" },
    { "theme": "CoWoS", "ticker": "2330", "company_name": "台積電", "role": "midstream" }
  ],
  "surveillance_status": "normal"
}

GET /api/market-surveillance/events

查詢台股注意/處置歷史事件。資料來源為 TWSE 與 TPEx 官方公告;包含一般股票與不可映射到股票代號的權證/可轉債等原始列。

Query Params:

名稱 類型 必填 預設 說明
ticker string 否 — 股票代號,如 3026.TW
status string 否 — attention 或 disposition
status_type string 否 — status 的別名
exchange string 否 — twse 或 tpex
source_exchange string 否 — exchange 的別名
start_date date 否 — 公告起始日期 YYYY-MM-DD
date_from date 否 — start_date 的別名
end_date date 否 — 公告結束日期 YYYY-MM-DD
date_to date 否 — end_date 的別名
active_on date 否 — 查指定日期有效資料;處置用 effective period,注意用 announcement date
include_unmapped bool 否 false 是否包含無法映射成股票 ticker 的公告列
limit int 否 100 回傳筆數(1-500)
offset int 否 0 分頁 offset

Response (200):

{
  "results": [
    {
      "id": 7,
      "ticker": "3026.TW",
      "security_code": "3026",
      "security_name": "禾伸堂",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-25",
      "effective_start_date": "2026-05-26",
      "effective_end_date": "2026-06-08",
      "cumulative_count": 3,
      "trading_information": null,
      "disposition_condition": "連續三次",
      "disposition_measure": "第二次處置",
      "disposition_content": "以人工管制之撮合終端機執行撮合作業(約每二十分鐘撮合一次)。",
      "matching_interval_minutes": 20,
      "close_price": null,
      "pe_ratio": null,
      "source_url": "https://www.twse.com.tw/announcement/punish?response=json&startDate=20260525&endDate=20260527"
    }
  ],
  "rows": [
    { "id": 7, "ticker": "3026.TW", "status_type": "disposition" }
  ],
  "total": 1,
  "summary": {
    "total": 1,
    "returned": 1,
    "by_status": { "disposition": 1 },
    "by_exchange": { "twse": 1 }
  }
}

rows 與 results 內容相同;保留兩者是為了支援較口語的 API 名稱與原計畫中的 rows 命名。

matching_interval_minutes 代表處置撮合頻率分鐘數;例如官方公告文字 約每五分鐘撮合一次 會回傳 5,約每二十分鐘撮合一次 會回傳 20。注意事件或無法解析處置文字時為 null。


GET /api/market-surveillance/active-dispositions

查詢指定日期的市場監管清單。預設回傳仍在處置中的股票名單;若傳 status=attention,則回傳 as_of 當日或之前最新可用公告日的注意資料,避免非公告日回傳空清單。原始事件查詢仍使用 /api/market-surveillance/events。

Query Params:

名稱 類型 必填 預設 說明
as_of date 否 今天(台北時間) 處置模式為有效處置判斷日期;注意模式為查詢基準日,API 會找 <= as_of 的最新可用注意公告日
status string 否 disposition disposition 或 attention
status_type string 否 — status 的別名;若兩者同時存在,以 status 優先
exchange string 否 — twse 或 tpex
source_exchange string 否 — exchange 的別名
limit int 否 500 回傳筆數(1-1000)
offset int 否 0 分頁 offset

status=disposition 或未傳 status/status_type 時,每個 ticker 最多回傳一筆;若同一檔股票有多筆重疊有效處置事件,取最新公告日與最新 id 的有效處置事件。status=attention 時不做 ticker dedupe,dedupe_by 會回傳 none,且 summary.requested_as_of 代表前端要求日期、summary.data_as_of 代表實際回傳的注意公告日期、summary.date_mode 固定為 latest_available。

Response (200):

{
  "results": [
    {
      "id": 628,
      "ticker": "6806.TW",
      "security_code": "6806",
      "security_name": "森崴能源",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-28",
      "effective_start_date": "2026-05-29",
      "effective_end_date": "2026-06-11",
      "disposition_measure": "人工管制撮合",
      "matching_interval_minutes": 60
    },
    {
      "id": 193,
      "ticker": "4916.TW",
      "security_code": "4916",
      "security_name": "事欣科",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-25",
      "effective_start_date": "2026-05-26",
      "effective_end_date": "2026-06-08",
      "disposition_measure": "第一次處置",
      "matching_interval_minutes": 5
    }
  ],
  "rows": [
    {
      "id": 628,
      "ticker": "6806.TW",
      "security_code": "6806",
      "security_name": "森崴能源",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-28",
      "effective_start_date": "2026-05-29",
      "effective_end_date": "2026-06-11",
      "disposition_measure": "人工管制撮合",
      "matching_interval_minutes": 60
    },
    {
      "id": 193,
      "ticker": "4916.TW",
      "security_code": "4916",
      "security_name": "事欣科",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-25",
      "effective_start_date": "2026-05-26",
      "effective_end_date": "2026-06-08",
      "disposition_measure": "第一次處置",
      "matching_interval_minutes": 5
    }
  ],
  "total": 2,
  "summary": {
    "total": 2,
    "returned": 2,
    "as_of": "2026-05-29",
    "by_status": { "disposition": 2 },
    "by_exchange": { "twse": 2 },
    "dedupe_by": "ticker"
  }
}

Attention mode date fallback example:

GET /api/market-surveillance/active-dispositions?as_of=2026-06-03&status=attention&limit=500
{
  "results": [
    {
      "id": 8001,
      "ticker": "9110.TW",
      "security_code": "9110",
      "security_name": "越南控-DR",
      "source_exchange": "twse",
      "status_type": "attention",
      "announce_date": "2026-06-02",
      "effective_start_date": null,
      "effective_end_date": null,
      "matching_interval_minutes": null
    }
  ],
  "rows": [
    {
      "id": 8001,
      "ticker": "9110.TW",
      "security_code": "9110",
      "security_name": "越南控-DR",
      "source_exchange": "twse",
      "status_type": "attention",
      "announce_date": "2026-06-02",
      "effective_start_date": null,
      "effective_end_date": null,
      "matching_interval_minutes": null
    }
  ],
  "total": 36,
  "summary": {
    "total": 36,
    "returned": 1,
    "as_of": "2026-06-02",
    "requested_as_of": "2026-06-03",
    "data_as_of": "2026-06-02",
    "date_mode": "latest_available",
    "by_status": { "attention": 1 },
    "by_exchange": { "twse": 1 },
    "dedupe_by": "none"
  }
}

GET /api/market-surveillance/exited-dispositions

查詢指定日期「已經離開處置」的股票。API 會先解析 as_of 前一個交易日,並查詢 effective_end_date 等於該日期的處置事件;若無法取得交易日,會以 as_of - 1 day 作為 fallback。回傳的 summary.exit_effective_end_date 會標示實際用來比對的處置結束日。

Query Params:

名稱 類型 必填 預設 說明
as_of date 否 今天(台北時間) 查詢哪一天已經離開處置
exchange string 否 — twse 或 tpex
source_exchange string 否 — exchange 的別名
limit int 否 500 回傳筆數(1-1000)
offset int 否 0 分頁 offset

Response (200):

{
  "results": [
    {
      "id": 628,
      "ticker": "6806.TW",
      "security_code": "6806",
      "security_name": "森崴能源",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-28",
      "effective_start_date": "2026-05-29",
      "effective_end_date": "2026-06-11",
      "disposition_measure": "人工管制撮合",
      "matching_interval_minutes": 60
    }
  ],
  "rows": [
    {
      "id": 628,
      "ticker": "6806.TW",
      "security_code": "6806",
      "security_name": "森崴能源",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-28",
      "effective_start_date": "2026-05-29",
      "effective_end_date": "2026-06-11",
      "disposition_measure": "人工管制撮合",
      "matching_interval_minutes": 60
    }
  ],
  "total": 1,
  "summary": {
    "total": 1,
    "returned": 1,
    "as_of": "2026-06-12",
    "by_status": { "disposition": 1 },
    "by_exchange": { "twse": 1 },
    "dedupe_by": "ticker",
    "exit_date_field": "effective_end_date",
    "exit_effective_end_date": "2026-06-11"
  }
}

GET /api/market-surveillance/stocks/{ticker}/status

查詢單一股票目前監管狀態與近期相關事件。處置中的股票優先回傳 disposition;若沒有有效處置,但近期有注意公告,回傳 attention;否則為 normal。

Query Params:

名稱 類型 必填 預設 說明
as_of date 否 今天(台北時間) 指定狀態判斷日期
lookback_days int 否 config 預設 注意股回看天數

Response (200):

{
  "ticker": "3026.TW",
  "status": "disposition",
  "as_of": "2026-05-29",
  "near_disposition_threshold": null,
  "is_attention": true,
  "is_disposition": true,
  "latest_attention": {
    "id": 9,
    "ticker": "3026.TW",
    "security_code": "3026",
    "security_name": "禾伸堂",
    "source_exchange": "twse",
    "status_type": "attention",
    "announce_date": "2026-05-27",
    "effective_start_date": null,
    "effective_end_date": null
  },
  "active_disposition": {
    "id": 7,
    "ticker": "3026.TW",
    "security_code": "3026",
    "security_name": "禾伸堂",
    "source_exchange": "twse",
    "status_type": "disposition",
    "announce_date": "2026-05-25",
    "effective_start_date": "2026-05-26",
    "effective_end_date": "2026-06-08",
    "matching_interval_minutes": 20
  },
  "active_events": [
    {
      "id": 7,
      "ticker": "3026.TW",
      "security_code": "3026",
      "security_name": "禾伸堂",
      "source_exchange": "twse",
      "status_type": "disposition",
      "announce_date": "2026-05-25",
      "effective_start_date": "2026-05-26",
      "effective_end_date": "2026-06-08",
      "cumulative_count": 3,
      "trading_information": null,
      "disposition_condition": "連續三次",
      "disposition_measure": "第二次處置",
      "disposition_content": "以人工管制之撮合終端機執行撮合作業(約每二十分鐘撮合一次)。",
      "matching_interval_minutes": 20,
      "close_price": null,
      "pe_ratio": null,
      "source_url": "https://www.twse.com.tw/announcement/punish?response=json&startDate=20260525&endDate=20260527"
    }
  ],
  "recent_events": [
    { "id": 7, "ticker": "3026.TW", "status_type": "disposition" }
  ]
}

near_disposition_threshold 為顯示用的近處置價量門檻;只有當該股票距離處置條件只差一次可計算的注意事件時才回傳物件,否則為 null。此物件只包含價量門檻欄位,不回傳「連續幾日」或「幾日內幾次」等內部累計規則文字。

回傳物件範例:

{
  "as_of": "2026-05-29",
  "trading_date": "2026-05-29",
  "source_exchange": "twse",
  "upper_close": 612.0,
  "lower_close": null,
  "min_volume": 2500000
}
欄位 型別 說明
as_of date 本次判斷日期
trading_date date 門檻適用交易日
source_exchange string twse 或 tpex
upper_close number/null 收盤價大於等於此值時可能觸發可計算的上漲方向注意門檻
lower_close number/null 收盤價小於等於此值時可能觸發可計算的下跌方向注意門檻
min_volume integer/null 成交量大於等於此股數時可能觸發可計算的量能門檻

GET /api/stocks/{ticker}/ohlc

取得 OHLC 歷史資料。

Query Params:

名稱 類型 必填 預設 說明
start_date string 否 — 起始日期 YYYY-MM-DD
end_date string 否 — 結束日期 YYYY-MM-DD
limit int 否 100 回傳筆數(1-5000)
basis string 否 raw 價格基準:raw = 實際成交價(未還原),adj = 還原價(分割/除權息調整)。其他值回 422

basis=adj 讀 stock_ohlc.adj_open/adj_high/adj_low/adj_close,欄位名稱與 raw 相同(open/high/low/close),前端不需改 parser。volume 兩種基準都是實際成交股數(成交量無還原概念)。還原價缺漏的列會被略過、不會用 raw 補(混基準序列比缺列更有害,issue #1253);因此 adj 的筆數可能少於 raw。要把 K 線與還原基準的指標歷史疊在同一張圖時用 adj,只看實際成交價用預設 raw。

Response:

{
  "ticker": "2330.TW",
  "data": [
    {
      "date": "2024-03-15",
      "open": 840.0,
      "high": 855.0,
      "low": 838.0,
      "close": 850.0,
      "volume": 25000000
    }
  ],
  "total": 100
}

GET /api/stocks/markets/list

列出可用市場。


GET /api/stocks/{ticker}/realtime-quote

取得台股盤中即時報價的最新一筆快照(issue #1836)。

🔒 Auth: 需登入(Authorization: Bearer <access_token>)並具備 realtime_quote 權限。無效/過期 token 回 401;未帶 Authorization header 回 422(與其他需登入 endpoint 一致);帳號停權回 403;權限未開啟回 403 FEATURE_REQUIRED(見下方「即時報價權限」)。

即時報價權限(realtime_quote)

以下 7 個端點都要求會員的有效權限含 realtime_quote: true,否則回 403 FEATURE_REQUIRED:/api/stocks/{ticker}/realtime-quote、/api/stocks/{ticker}/realtime-quote/stream、/api/stocks/{ticker}/intraday-ohlc、/api/market/realtime-quote、/api/market/realtime-quote/stream、/api/market/intraday-ohlc、/api/portfolio/realtime-quotes/stream(後三個盤中端點詳見 realtime-intraday-api.md)。

  • 有效權限:members.is_admin = true 的會員讀保留的 admin permission set;其他會員讀目前生效的 tier(member_entitlements,無則 free)。目前只有 admin permission set 開啟,free / advance / premium 皆為 false;要開放給某個 tier,由管理員以 PUT /api/admin/membership-tiers/{tier_name}/permissions 把該 tier 的 realtime_quote 改為 true,不需改程式。
  • 權限檢查先於 ticker/interval 驗證:沒有權限時,不存在的 ticker 或非法 interval 也回 403,而不是 404/422。例外是缺少必填參數(Authorization header、持股串流的 tickers):FastAPI 在進入權限檢查前就回 422。
  • SSE 端點在連線時檢查(403 為一般 HTTP 錯誤、串流不會開始),之後每輪輪詢連同 token 一起重新檢查;連線中失去權限(例如被取消 admin、tier 被關閉)時送出 unauthorized 事件並結束。
  • /api/members/me、/api/auth/me 回的 tier.features 是會員所屬 tier 的設定,不含 admin 的保留權限;前端判斷可否使用即時功能時,請以這些端點實際回 403 FEATURE_REQUIRED 為準。

資料來源為 FinMind taiwan_stock_tick_snapshot,scheduler 在台股交易日 09:00–14:35(Asia/Taipei;config/core.yaml 的 realtime_quote.session_start / session_end / post_close_grace_minutes)每 realtime_quote.poll_interval_seconds(目前 10)秒輪詢一次,寫入 taiwan.realtime_quote_snapshots。該表只保留最新一個交易日的列:換日後第一次輪詢成功時,前一交易日的列會被整表 retention DELETE 清除。total_volume/buy_volume/sell_volume/yesterday_volume 等量能欄位單位是張(lots)、為盤中累積值,與 /api/stocks/{ticker} 回傳的官方日成交量(股)不是同一個數字、不可互換。本端點不走 response cache。

Path Params:

名稱 類型 必填 說明
ticker string 是 台股代號,須帶 .TW 或 .TWO(大小寫皆可,會正規化為大寫);裸代號、非台股代號(如 AAPL)或未收錄於 taiwan.stocks 一律 404

Response — RealtimeQuoteResponse(200,已登入且 ticker 存在即回 200,不論有無快照):

{
  "ticker": "2330.TW",
  "status": "available",
  "quote": {
    "stock_code": "2330",
    "trading_date": "2026-10-01",
    "quote_time": "2026-10-01T03:35:36.513503Z",
    "fetched_at": "2026-10-01T03:35:40Z",
    "name": null,
    "open": 2490.0,
    "high": 2505.0,
    "low": 2485.0,
    "close": 2500.0,
    "change_price": 20.0,
    "change_rate": 0.8,
    "average_price": 2495.5,
    "buy_price": 2500.0,
    "sell_price": 2505.0,
    "volume_ratio": 0.28,
    "volume": 1,
    "total_volume": 8587,
    "amount": 2500000,
    "total_amount": 21428895000,
    "buy_volume": 69,
    "sell_volume": 1071,
    "yesterday_volume": 30460,
    "tick_type": 2
  }
}

上例數值取自 2330 於 2026-10-01 盤中的實際 FinMind 快照(fetched_at 為示意)。name 只有指數列(如 FinMind 001 加權指數)才有值,個股為 null;quote_time/fetched_at 以 UTC(Z)序列化,顯示時請轉 Asia/Taipei。前端應比對 trading_date 與 /api/stocks/{ticker} 的日資料日期,決定顯示即時值或日收盤。

「最新一筆」指該檔最後收到的快照:fetched_at 最新者,同一輪輪詢寫入的多列才以 quote_time 較晚者為準;不是 quote_time 最大的那筆——若以 quote_time 排序,一筆時間戳異常的列(如同日 23:59:59)會蓋住之後所有真實報價。

fetched_at 是首次寫入或最後一次內容有變動的那一輪輪詢的起始時間(內容完全不變的快照不會更新它),因此不能當作輪詢器是否仍在運作的存活訊號。

表內查無該檔任何快照時 status 為 "no_data"、quote 為 null,ticker 仍正常回傳——例如最新交易日該檔尚無成交(新交易日第一次輪詢成功後前一日列已清除),或 FinMind 快照本來就不含該檔(部分 REIT、ETN 等)。非交易日或開盤前,表內仍保留最近一個交易日的列,此時回 available、trading_date 為該交易日。數值欄位來源缺值或無法解析時逐欄位回 null(不影響同一列其他欄位)。

欄位 類型 說明
ticker string 正規化後的外部 ticker({stock_code}.TW 或 .TWO)
status "available" | "no_data" 是否存在任何快照列
quote.stock_code string FinMind 回傳的 stock_id(與 taiwan.stocks.stock_code 相同)
quote.trading_date string(YYYY-MM-DD) 該筆快照所屬交易日
quote.quote_time string(ISO-8601, UTC,...Z 結尾) FinMind tick 的時間戳;DB 存 timestamptz,經 asyncpg 讀回後一律帶時區,由 Pydantic 以 UTC 序列化(非台北時間字串)
quote.fetched_at string(ISO-8601, UTC,...Z 結尾) 這筆快照被輪詢寫入的時間(非 FinMind 回傳時間),序列化格式同 quote_time
quote.name string | null FinMind 回傳的名稱;實測只有指數列(如 001 加權指數)有此欄,個股為 null
quote.open / high / low / close / change_price / average_price / buy_price / sell_price number | null 元;對應 DB NUMERIC(12,4)
quote.change_rate / volume_ratio number | null FinMind 原始數值直接轉存,未做任何單位換算
quote.volume integer | null 該筆 tick 本身的張數
quote.total_volume / buy_volume / sell_volume / yesterday_volume integer | null 盤中累積張數(見上方單位說明,非官方日成交股數)
quote.amount integer | null 該筆 tick 的成交金額(元)
quote.total_amount integer | null 盤中累積成交金額(元)
quote.tick_type integer | null FinMind 原始 TickType

Error Responses:

狀態碼 情境 Body
422 未帶 Authorization header {"detail": [{"type": "missing", "loc": ["header", "authorization"], "msg": "Field required", "input": null}]}(與所有需登入 endpoint 相同的 FastAPI 驗證錯誤)
401 token 無效或過期 {"error": {"code": "INVALID_TOKEN", "message": "...", "details": {}}} 或 {"error": {"code": "TOKEN_EXPIRED", "message": "Access token has expired", "details": {}}}
403 帳號停權 {"error": {"code": "MEMBER_SUSPENDED", "message": "Account is suspended", "details": {}}}
403 會員的有效權限未開啟 realtime_quote(目前只有 admin 開啟) {"error": {"code": "FEATURE_REQUIRED", "message": "This membership does not include realtime_quote", "details": {}}}
404 裸代號、非台股代號、或未收錄於 taiwan.stocks {"error": {"code": "Data not found", "message": "Data not found", "details": {}}}

GET /api/stocks/{ticker}/realtime-quote/stream

以 SSE 持續推送上一端點同一檔股票的即時報價(issue #1836)。

🔒 Auth: 需登入(Authorization: Bearer <access_token>)並具備 realtime_quote 權限(見上方「即時報價權限」)。連線時的驗證、權限與 ticker 錯誤是一般 HTTP 錯誤(與上方 REST 端點同碼同 body:未帶 header 422、無效 token 401、停權 403、無權限 403 FEATURE_REQUIRED、ticker 不存在 404),尚未開始送 SSE 事件;連線成功後,每次輪詢都會重新驗證 token 與 realtime_quote 權限。

Response(200): Content-Type: text/event-stream; charset=utf-8,headers 含 Cache-Control: no-cache, no-transform、X-Accel-Buffering: no、Connection: keep-alive(避免中間 proxy 緩衝整條串流)。Framing 與心跳與 /api/recaps/{period_type}/{date}/analyze 的 SSE 模式共用同一支 api/sse_helpers.py:每個事件為 event: msg\ndata: {json}\n\n,靜默期每 mas.sse_heartbeat_interval_seconds(config/core.yaml,目前 15 秒)送出一則 : keep-alive SSE comment(不是資料事件,前端可忽略)。

事件 data JSON,type 共四種:

type 其他欄位 說明
quote status("available" | "no_data")、quote(同 REST 端點 RealtimeQuote,或 null) 連線後立即送出目前最新快照一次(「最新」定義同 REST 端點:最後收到的一筆,見上;該股目前沒有快照時送 status: "no_data"、quote: null),之後每輪詢(realtime_quote.poll_interval_seconds,目前 10 秒)重新讀取最新快照,當狀態與上次送出的不同才再送一次:快照的 quote_time 或 fetched_at 改變(同一 quote_time 的原地內容更新也會觸發重送)、由無資料變成有快照、或由有快照變成無資料(換日後第一次輪詢成功時,前一交易日的列被 retention DELETE 清除、而該股當日尚無新列:串流會推送 {"type":"quote","status":"no_data","quote":null},與此時 REST 端點回 no_data 一致,不會停留在前一日的舊報價);狀態沒有變化(含持續無資料)不會重送
session_closed (無) 交易日的輪詢窗口結束(session_end + post_close_grace_minutes,目前 14:35 台北時間)後再過一個輪詢間隔(realtime_quote.poll_interval_seconds,目前 10 秒,即 14:35:10)已過,或當天非官方 TWSE 交易日:送出最新一筆 quote 事件後即送這個事件並結束串流
token_expired (無) 輪詢時重新驗證發現 token 已過期:送出後立即結束串流;前端應刷新 token 後重新連線
unauthorized (無) 輪詢時重新驗證失敗(如帳號被停權、失去 realtime_quote 權限、或伺服器端權限設定無法讀取):送出後立即結束串流;前端不應自動重連,也不應一律視為登出

生命週期: 連線成功後,若當天是官方 TWSE 交易日且尚未過串流結束時間,串流在整個交易日內(含開盤前連線)持續開著,直到輪詢窗口結束後再一個輪詢間隔(目前 14:35 + 10 秒 = 14:35:10):scheduler 可能在 14:35:00 才開始最後一輪、幾秒後才寫入,多留這一個間隔才能把最後一筆推給前端(前端收到 session_closed 後不會重連);scheduler 本身的輪詢窗口仍只到 14:35。串流每次等待取「輪詢間隔」與「距串流結束時間剩餘時間」中較小者,並在 14:35:10 當下做最後一次讀取(有變化則推送)後立刻送 session_closed,不會因為剛好在結束前一刻才通過檢查而多等一個輪詢間隔。非交易日連線或已過串流結束時間,會先送一次目前最新快照(quote 事件)再立刻送 session_closed 並結束,不會一直掛著。伺服器端以 in-process 計數器追蹤目前開啟的串流數(operational log,非回應內容)。


GET /api/portfolio/realtime-quotes/stream

以單一 SSE 連線批次推送多檔台股的最新盤中報價,供持股估值更新使用(issue #1877)。

🔒 Auth: 需登入並具備 realtime_quote 權限,規則與單檔 /api/stocks/{ticker}/realtime-quote/stream 相同。未帶 Authorization header 回 422;無效或過期 token 回 401;帳號停權回 403;無權限回 403 FEATURE_REQUIRED(先於 tickers 內容驗證;完全沒帶 tickers 參數時仍由 FastAPI 參數驗證先回 422)。連線成功後每輪只重新驗證 token 與權限一次,並以同一個短生命週期 DB session 批次讀取所有 ticker;session 會在送出事件或等待下一輪前關閉。

Query Params:

名稱 類型 必填 說明
tickers string 是 逗號分隔的台股 ticker(須帶 .TW 或 .TWO),最多 100 個輸入項目;大小寫正規化後去重。空值、空項目或超過 100 個回 422;非台股、格式錯誤、或任一 ticker 未收錄回 404。上限在去重前套用。

範例:

GET /api/portfolio/realtime-quotes/stream?tickers=2330.TW,6488.TWO
Authorization: Bearer <access_token>

Response(200): Content-Type: text/event-stream; charset=utf-8,SSE framing、headers 與每 15 秒 keep-alive comment 沿用單檔串流。首次 quotes 事件的 items 包含所有正規化、去重後的 ticker;後續只包含狀態或快照有變化的 ticker。每個 quote 完整沿用單檔 RealtimeQuote shape、nullable 數值、UTC Z 時間與成交量張數單位。

event: msg
data: {"type":"quotes","items":{"2330.TW":{"status":"available","quote":{"stock_code":"2330","trading_date":"2026-10-05","quote_time":"2026-10-05T05:20:00Z","fetched_at":"2026-10-05T05:20:03Z","name":null,"open":1400.0,"high":1420.0,"low":1395.0,"close":1415.0,"change_price":15.0,"change_rate":1.07,"average_price":1408.5,"buy_price":1410.0,"sell_price":1415.0,"volume_ratio":0.8,"volume":2,"total_volume":18000,"amount":2830000,"total_amount":25300000000,"buy_volume":9000,"sell_volume":9000,"yesterday_volume":22000,"tick_type":2}},"6488.TWO":{"status":"no_data","quote":null}}}

type 另有 session_closed、token_expired 與 unauthorized,語意與單檔串流完全相同;目前收盤 cutoff 為 14:35:10(Asia/Taipei)。token_expired 可刷新 token 後重連;unauthorized 與 session_closed 不應自動重連。伺服器讀取錯誤或 client 中止時會關閉 generator 與短生命週期 session。

Error Responses:

狀態碼 情境 Body
422 缺少 auth header、缺少/空白 tickers、含空項目、或輸入項目超過 100 缺 header/query 時沿用 FastAPI validation body;自訂 query 驗證沿用全域 error envelope
401 token 無效或過期 沿用既有 INVALID_TOKEN / TOKEN_EXPIRED error envelope
403 帳號停權 沿用既有 MEMBER_SUSPENDED error envelope
403 會員的有效權限未開啟 realtime_quote(目前只有 admin 開啟) {"error": {"code": "FEATURE_REQUIRED", "message": "This membership does not include realtime_quote", "details": {}}}
404 格式錯誤、非台股、ticker 不存在、或 suffix 不是有效 current/history alias {"error":{"code":"Data not found","message":"Data not found","details":{}}}

6. 技術指標 Indicators

GET /api/stocks/{ticker}/indicators

取得技術指標數據。支援兩種模式:

  • 快照模式(預設):latest=true,回傳最新一筆指標快照。
  • 歷史模式:latest=false,回傳最近 N 個完整交易日資料列的指標時間序列,適用於繪製技術指標圖。 序列只包含 data_quality='complete' 的資料列;每列 provenance 保留作稽核,V1 時代 的資料列仍是合法歷史。可用完整資料列少於 N 時,total 會小於請求的 days。

Query Params:

名稱 類型 必填 預設 說明
latest bool 否 true true 回傳最新快照;false 回傳歷史序列
days int 否 66 取最近 N 個完整交易日資料列(1-200,僅 latest=false 時生效)
fields string 否 null 逗號分隔的指標群組名稱,如 rsi,macd,ma,僅回傳指定指標

days 上限由 config/core.yaml 的 indicators.series_max_days 控制(預設 200)。 當 latest=true 時,days 參數會被忽略。

歷史模式的 days=N 不是日曆日 cutoff:先在 data_quality='complete' 的 snapshots 取日期最新的 N 筆,再依日期升冪回傳;因此週末、休市或 暫停交易不會讓回應少於可用的交易日資料列。input_basis='adjusted' 的價格型指標才會用該筆交易日 有效的 factor 投影到 raw OHLC 顯示尺度;input_basis='raw' 的列不轉換。basis 為 null、未知值,或 adjusted 列缺 factor 時,該日仍保留在序列中,但僅價格型 leaf 值為 null。

exact-D 契約與「尚未就緒」語意(issue #1356 C5,⚠️ 破壞性變更、永久生效): latest=true 回傳的快照恆為該 ticker當前 raw OHLC 最新交易日(下稱 raw D) 的資料,snapshot 讀取路徑與 fallback 重算路徑皆對齊同一 D,不再是「資料庫裡 隨便一筆最新列」。

行為變更:同樣的過期/不完整資料狀況,舊版回 200 但夾帶錯的數字 (可能是過期的 D-1 完整列,或 D 當天 NULL 值散落各群組的不完整列); 本版回 200 但 indicators 為空物件:

{
  "ticker": "020000.TW",
  "timestamp": "2026-08-19T14:52:03.123456",
  "trading_date": "2026-08-05",
  "indicators": {}
}

trading_date 仍然給值——「這是我們現在所在的那一天,而它的指標還沒就緒」。 空 body 而不給日期會與「查無此 ticker」(404)無法區分。

indicators 為 {} 只有這一個意思。 ?fields= 的值會被驗證:未知的群組名回 422,不會退化成空 indicators。(改動前它會,且與本狀態的 body 形狀完全相同, 呼叫端無法分辨。)另需與「群組在、值為 null」區分——那代表指標算過了, 只是該檔歷史長度不足以暖身。

為什麼不是 503(2026-08-19 裁決):這個狀態每個交易日都會出現一次——raw 收盤 之後、adjusted 批次跑完之前——是一天當中的正常階段,不是故障。503 對前端的意思 是「伺服器壞了,重試」,而空指標配上日期說的是實話,前端可以據此顯示「資料準備中」 而不是錯誤。

兩種情況(過期 D-1 完整列、當天 NULL 值列)都不會被回傳——空物件是本端點承認 「這一天還答不出來」的方式。adjusted OHLC 補齊後,下一次請求即自行恢復正常內容。

Response(快照模式 latest=true):

{
  "ticker": "2330.TW",
  "timestamp": "2024-03-15T15:00:00",
  "trading_date": "2024-03-15",
  "indicators": {
    "ma": { "5": 848.0, "10": 845.0, "20": 840.0, "50": 830.0, "60": 828.0, "100": 815.0, "120": 812.0, "200": 800.0, "240": 795.0 },
    "ema": { "5": 849.0, "10": 844.0, "20": 839.0, "50": 832.0, "60": 830.5, "100": 818.0, "120": 815.0, "200": 805.0, "240": 800.0 },
    "rsi": 65.3,
    "macd": { "value": 5.2, "signal": 3.1, "histogram": 2.1 },
    "kd": { "k": 72.5, "d": 68.1 },
    "bollinger": { "upper": 870.0, "middle": 845.0, "lower": 820.0 },
    "adx": 25.0,
    "atr": 15.0,
    "obv": 1234567,
    "willr": -32.5,
    "di": { "plus": 29.77300985074976, "minus": 24.411860567930212 },
    "vortex": { "plus": 1.174329501915709, "minus": 0.8045977011494256 },
    "volume_ma": { "5": 25000000, "20": 22000000 },
    "volume_price_anomaly": {
      "price_return_1d": 0.031,
      "price_z_20d": 2.3,
      "volume_z_20d": 2.5,
      "baseline_count": 20,
      "baseline_span_days": 27,
      "state": "surge_up"
    },
    "vwap": 847.5,
    "ichimoku": { "tenkan": 845.0, "kijun": 838.0 },
    "cci": 85.3,
    "mfi": 62.1,
    "chop": 45.8,
    "wavetrend": { "wt1": 12.5, "wt2": 10.3 },
    "aroon": { "up": 85.7, "down": 14.3, "oscillator": 71.4 },
    "keltner": { "upper": 865.0, "middle": 845.0, "lower": 825.0 },
    "supertrend": { "value": 830.0, "direction": "bullish" },
    "donchian": { "high_20": 870.0, "low_20": 820.0, "high_55": 890.0, "low_55": 790.0 },
    "tii": 65.2,
    "wvixf": { "value": 8.5, "upper": 15.0, "panic": false },
    "elder_ray": { "bull_power": 12.5, "bear_power": -3.2 },
    "weis_wave": { "volume": 5000000, "direction": 1.0, "price_delta": 5.5 },
    "qqe": { "rsi_smooth": 62.3, "line": 58.1, "above_line": true },
    "szo": 35.2,
    "vsa": { "no_supply_detected": false, "no_supply_confirmed": false }
  }
}

indicators 共包含 31 個指標群組(78 個 leaf 值)。權威來源是 api/routes/stocks.py 的 DB_COLUMN_TO_API_GROUP 映射表, INDICATOR_GROUP_NAMES 由它推導而來,也就是 ?fields= 接受的全部值。 trading_date(新增欄位,issue #1356 C5)=上方 exact-D 契約所指的 raw D,YYYY-MM-DD 格式;既有欄位(ticker/timestamp/indicators)不變, 純附加不移除,相容既有讀者。

volume_price_anomaly 使用今日以前 20 個共同有效交易觀測值,價格為 adj_close 對數報酬、成交量為原始股數的 log1p,兩者標準差皆採樣本標準差 (ddof=1)。今日不會進入自己的基準。量與價同時達 2σ,且對稱的簡單報酬 (exp(price_return_1d) - 1)漲跌至少 0.5%, state 才會是 surge_up 或 surge_down;歷史不足、標準差為零、還原價缺漏、 今日零量、從最舊基準觀測到今日的日曆跨度超過 45 日(含長停牌/缺洞) 或未達門檻時為 null,不以 raw close 回補。

Response(歷史模式 latest=false):

{
  "ticker": "2330.TW",
  "days": 66,
  "total": 66,
  "data": [
    {
      "timestamp": "2024-01-10T00:00:00",
      "indicators": {
        "rsi": 58.2,
        "macd": { "value": 3.1, "signal": 2.8, "histogram": 0.3 }
      }
    }
  ]
}

indicators 結構與快照模式相同(群組數見上方權威來源說明,不在此重述)。使用 fields 可篩選回傳的指標群組,如 fields=rsi,macd,aroon。契約: 群組名大小寫敏感、一律小寫(與回應中的鍵一致),RSI 會回 422; 未知群組名回 422;只有分隔符(,,,)也回 422; 省略、空字串或全空白 = 不篩選。尾逗號、重複名與空白填充皆可接受。

本端點的 422 有兩種 body 形狀,OpenAPI 以 anyOf 同時宣告,各有具名 schema: IndicatorsParameterValidationError(detail 是 field error 陣列,例如 days 傳非數字) 與 IndicatorsRequestRejected(detail 是字串——fields 的兩種錯誤、days 超過上限)。 依 spec 產型別的 client 需處理兩者。


7. 三大法人 Institutional

Prefix: /api/institutional

個股金額估算基礎

以下兩個 additive endpoint 以現有法人股數與同日收盤價估算個股金額:

estimated_buy_amount = buy_shares × same-day close
estimated_sell_amount = sell_shares × same-day close
estimated_net_amount = net_shares × same-day close
period estimated_net_amount = Σ(daily net_shares × same-day close)

這些欄位是以每日收盤價估算的新台幣金額,不是交易所提供的個股實際成交金額。呼叫方必須以 response 的 amount_basis="net_shares_x_daily_close" 判斷口徑,不得將估算值標示成官方成交金額。多日金額先逐日計算再加總,不使用「期間淨股數 × 最新收盤價」。

days 在這兩個 endpoint 都表示交易日,不是日曆日:

  • 排行 API:以全市場法人資料最新日起,精確取最近 N 個 distinct trade_date。
  • 個股 API:以 canonical ticker 自己的法人資料,精確取最近 N 筆 trade_date。
  • 歷史少於 N 日時回 HTTP 200,actual_days 為實際可得日數。

GET /api/institutional/amount-ranking

回傳最近 N 個交易日內,各法人按「逐日估算淨買賣金額加總」計算的買超榜與賣超榜。這是公開、唯讀 endpoint。排名日為 institutional_investors 不晚於官方交易日的最新一日;上市、上櫃分批到時任一批到就換日,未到的那批當天不在榜上。

Query Params:

名稱 類型 Required Nullable 預設 驗證 / 說明
days integer no no 1 1..30;最近交易日數
limit integer no no 20 1..100;買超榜與賣超榜各自最多筆數
investor_type enum no no all all、foreign、trust、dealer;all 回前三組及第四組 total,不開放直接傳 total

Response 200 — top-level:

欄位 類型 Nullable 說明
requested_days integer no 呼叫方要求的交易日數
actual_days integer no 實際納入視窗的 distinct 交易日數
start_date date yes 實際視窗最早交易日;無資料時為 null
end_date date yes 實際視窗最新交易日;無資料時為 null
investor_type string no 套用的法人篩選
limit integer no 每側排行上限
currency string no 固定 TWD,base unit
amount_basis string no 固定 net_shares_x_daily_close
rankings InstitutionalAmountRankingGroup[] no 法人分組排行;無資料時仍保留 requested actor 分組,其買賣榜為空

InstitutionalAmountRankingGroup:

欄位 類型 Nullable 說明
investor_type string no foreign、trust、dealer 或 total
investor_name string no 外資、投信、自營商 或 三大法人合計
excluded_unpriced_tickers integer no 視窗內有非零 actor net_shares、但至少一個必要同日 close 缺漏,因而未參與此 actor 排行的 ticker 數
buy InstitutionalAmountRankingItem[] no estimated_net_amount > 0;金額由大到小
sell InstitutionalAmountRankingItem[] no estimated_net_amount < 0;金額由小到大,賣超絕對值最大者在前

InstitutionalAmountRankingItem:

欄位 類型 Nullable 說明
rank integer no 從 1 起算的穩定 ROW_NUMBER
ticker string no canonical ticker,例如 2330.TW
name string yes 股票名稱
direction string no buy 或 sell
net_shares integer no 視窗內每日淨股數加總
estimated_net_amount number no Σ(daily net_shares × same-day close),TWD

排序規則:

  • buy 依 estimated_net_amount DESC, ticker ASC。
  • sell 依 estimated_net_amount ASC, ticker ASC。
  • 零淨額不進任一榜。
  • investor_type=all 時,rankings 固定依 foreign、trust、dealer、total 排序;total 是前三者逐日淨股數合計,不是散戶金額。指定單一法人時只有一組。
  • 若 ticker 在該 actor 視窗內任何非零 net_shares 日期缺少同日 close,整個 ticker 從該 actor 的買、賣排行排除,避免以不完整金額低估後仍參與名次。其他 actor 不受影響。

Request example:

GET /api/institutional/amount-ranking?days=5&limit=20&investor_type=all

Response example:

{
  "requested_days": 5,
  "actual_days": 5,
  "start_date": "2026-07-17",
  "end_date": "2026-07-23",
  "investor_type": "all",
  "limit": 20,
  "currency": "TWD",
  "amount_basis": "net_shares_x_daily_close",
  "rankings": [
    {
      "investor_type": "foreign",
      "investor_name": "外資",
      "excluded_unpriced_tickers": 1,
      "buy": [
        {
          "rank": 1,
          "ticker": "3231.TW",
          "name": "緯創",
          "direction": "buy",
          "net_shares": 122234059,
          "estimated_net_amount": 20457525118.5
        }
      ],
      "sell": [
        {
          "rank": 1,
          "ticker": "2330.TW",
          "name": "台積電",
          "direction": "sell",
          "net_shares": -49033372,
          "estimated_net_amount": -112659401380.0
        }
      ]
    },
    {
      "investor_type": "trust",
      "investor_name": "投信",
      "excluded_unpriced_tickers": 0,
      "buy": [],
      "sell": []
    },
    {
      "investor_type": "dealer",
      "investor_name": "自營商",
      "excluded_unpriced_tickers": 12,
      "buy": [],
      "sell": []
    },
    {
      "investor_type": "total",
      "investor_name": "三大法人合計",
      "excluded_unpriced_tickers": 1,
      "buy": [
        {
          "rank": 1,
          "ticker": "3231.TW",
          "name": "緯創",
          "direction": "buy",
          "net_shares": 122234059,
          "estimated_net_amount": 20457525118.5
        }
      ],
      "sell": [
        {
          "rank": 1,
          "ticker": "2330.TW",
          "name": "台積電",
          "direction": "sell",
          "net_shares": -49033372,
          "estimated_net_amount": -112659401380.0
        }
      ]
    }
  ]
}

完全沒有交易日資料時仍回 HTTP 200,保留 request metadata,actual_days=0、start_date=null、end_date=null,並保留 requested actor 分組,其 buy=[]、sell=[]。若有交易日視窗但指定 actor 全為零淨額,行為相同。

GET /api/institutional/stock/{ticker}

回傳指定個股逐日三大法人買賣股數(外資/投信/自營商與合計),依 trade_date 由新到舊排序。

Path Params:

名稱 類型 Required Nullable 說明
ticker string yes no 4 位數字,須帶 .TW / .TWO;裸代號回 HTTP 404

Query Params:

名稱 類型 Required Nullable 預設 驗證 / 說明
days integer no no 30 1..3650;下界 = 台北今日往回 N 個日曆日。給了 start_date 時不採用,但仍驗證範圍
start_date string no yes — YYYY-MM-DD,含當日的下界;優先於 days
end_date string no yes — YYYY-MM-DD,含當日的上界;未給則不設上界
  • 日期格式錯誤、days 超出 1..3650、或 ticker 不符 ^\d{4}(\.TW|\.TWO)?$ 回 HTTP 422;日期格式先於裸代號 404 檢查。
  • 視窗內沒有任何資料列(含 end_date 早於 start_date)回 HTTP 404,與未帶日期參數時的既有行為相同。

Response 200: InstitutionalData[](ticker、trade_date、foreign_buy/foreign_sell/foreign_net、trust_buy/trust_sell/trust_net、dealer_buy/dealer_sell/dealer_net、total_net;單位為股)。

GET /api/institutional/stock/{ticker}/amount-flows

回傳指定個股最近 N 個法人資料交易日中,各法人的買賣股數、方向、同日收盤價與估算金額。這是公開、唯讀 endpoint。

Path Params:

名稱 類型 Required Nullable 說明
ticker string yes no 台灣 4–6 位數字,可帶一個大寫字母;可加 .TW / .TWO

Ticker resolution:

  • 已帶 .TW / .TWO 時只查明確 ticker。
  • 裸代號依 .TW、.TWO 候選解析;只有一個可用候選時回 canonical ticker。
  • 候選只計有名稱(name IS NOT NULL)的 ticker。 無名稱的列是資料匯入殘留的佔位列,不視為可解析標的(#1206)。
  • .TW 與 .TWO 同時存在相同裸代號且兩者皆有名稱時回 HTTP 409,呼叫方必須指定 suffix。
  • ticker 不存在,或存在但完全沒有法人資料時回 HTTP 404。

Query Params:

名稱 類型 Required Nullable 預設 驗證 / 說明
days integer no no 1 1..30;resolved ticker 自己最近 N 筆法人交易日
investor_type enum no no all all、foreign、trust、dealer;all 回前三組及第四組 total,不開放直接傳 total

Response 200 — top-level:

欄位 類型 Nullable 說明
ticker string no resolved canonical ticker
name string yes 股票名稱
requested_days integer no 呼叫方要求的交易日數
actual_days integer no 此 ticker 實際回傳的法人資料日數
start_date date no 實際最早資料日
end_date date no 實際最新資料日
investor_type string no 套用的法人篩選
currency string no 固定 TWD,base unit
amount_basis string no 固定 net_shares_x_daily_close
actors InstitutionalAmountFlowActor[] no 法人分組;all 時固定為 foreign、trust、dealer、total

InstitutionalAmountFlowActor:

欄位 類型 Nullable 說明
investor_type string no foreign、trust、dealer 或 total
investor_name string no 外資、投信、自營商 或 三大法人合計
items InstitutionalAmountFlowItem[] no 依 trade_date DESC 排列

InstitutionalAmountFlowItem:

欄位 類型 Nullable 說明
trade_date date no 法人資料交易日
direction string no buy(net > 0)、sell(net < 0)、neutral(net = 0)
buy_shares integer no 當日買進股數
sell_shares integer no 當日賣出股數
net_shares integer no 當日淨股數
close_price number yes 同日收盤價;缺價時為 null;數值 0 是有效價格
estimated_buy_amount number yes buy_shares × close_price;缺價且非零流量時為 null
estimated_sell_amount number yes sell_shares × close_price;缺價且非零流量時為 null
estimated_net_amount number yes net_shares × close_price;缺價且非零流量時為 null
amount_status string no available、missing_close 或 zero_flow

amount_status 語意:

值 條件 金額欄位
available 有同日 close;close=0 仍屬 available 按公式計算,可能為 0
missing_close buy/sell/net 任一股數非 0,但缺同日 close 三個 estimated_*_amount 均為 null
zero_flow buy_shares、sell_shares、net_shares 全為 0 三個 estimated_*_amount 均為 0;close 可為 null

Request example:

GET /api/institutional/stock/2330/amount-flows?days=1&investor_type=all

Response example:

{
  "ticker": "2330.TW",
  "name": "台積電",
  "requested_days": 1,
  "actual_days": 1,
  "start_date": "2026-07-23",
  "end_date": "2026-07-23",
  "investor_type": "all",
  "currency": "TWD",
  "amount_basis": "net_shares_x_daily_close",
  "actors": [
    {
      "investor_type": "foreign",
      "investor_name": "外資",
      "items": [
        {
          "trade_date": "2026-07-23",
          "direction": "sell",
          "buy_shares": 10889394,
          "sell_shares": 15503824,
          "net_shares": -4614430,
          "close_price": 2405.0,
          "estimated_buy_amount": 26188992570.0,
          "estimated_sell_amount": 37286696720.0,
          "estimated_net_amount": -11097704150.0,
          "amount_status": "available"
        }
      ]
    },
    {
      "investor_type": "trust",
      "investor_name": "投信",
      "items": [
        {
          "trade_date": "2026-07-23",
          "direction": "sell",
          "buy_shares": 322751,
          "sell_shares": 1266009,
          "net_shares": -943258,
          "close_price": 2405.0,
          "estimated_buy_amount": 776216155.0,
          "estimated_sell_amount": 3044751645.0,
          "estimated_net_amount": -2268535490.0,
          "amount_status": "available"
        }
      ]
    },
    {
      "investor_type": "dealer",
      "investor_name": "自營商",
      "items": [
        {
          "trade_date": "2026-07-23",
          "direction": "buy",
          "buy_shares": 657629,
          "sell_shares": 619173,
          "net_shares": 38456,
          "close_price": 2405.0,
          "estimated_buy_amount": 1581597745.0,
          "estimated_sell_amount": 1489111065.0,
          "estimated_net_amount": 92486680.0,
          "amount_status": "available"
        }
      ]
    },
    {
      "investor_type": "total",
      "investor_name": "三大法人合計",
      "items": [
        {
          "trade_date": "2026-07-23",
          "direction": "sell",
          "buy_shares": 11869774,
          "sell_shares": 17389006,
          "net_shares": -5519232,
          "close_price": 2405.0,
          "estimated_buy_amount": 28546806470.0,
          "estimated_sell_amount": 41820559430.0,
          "estimated_net_amount": -13273752960.0,
          "amount_status": "available"
        }
      ]
    }
  ]
}

缺價日期不會從個股行為清單移除;方向仍由 net_shares 決定,金額依上述 amount_status 規則回傳。

新增估算金額 endpoints 的錯誤回應

HTTP 條件 Body
404 ticker 不存在(Unknown ticker …),或股票存在但沒有任何法人資料(No institutional data found …) 全域 error envelope
409 裸代號同時命中 .TW 與 .TWO 全域 error envelope
422 ticker 格式錯誤;days、limit 或 investor_type 驗證失敗 FastAPI detail array
500 未預期服務錯誤 全域 error envelope

不存在 ticker 的 404 範例(股票存在但無法人資料時 code/message 為 No institutional data found for …;409/500 使用相同全域 envelope):

{
  "error": {
    "code": "Unknown ticker 9999.TW",
    "message": "Unknown ticker 9999.TW",
    "details": {}
  }
}

422:

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["query", "days"],
      "msg": "Input should be greater than or equal to 1",
      "input": "0",
      "ctx": {
        "ge": 1
      }
    }
  ]
}

這兩個 endpoint 不修改既有 /api/institutional/ranking/*、/api/institutional/stock/{ticker} 或其他 institutional response schema/status code。

GET /api/institutional/foreign/daily/{ticker}

外資每日買賣超。

Query Params:

名稱 類型 預設 說明
days int 30 天數
sort string — 排序欄位
limit int — 回傳筆數

GET /api/institutional/ranking/{investor_type}

個股層級三大法人買賣超排行。排名日為 institutional_investors 不晚於官方交易日的最新一日;上市、上櫃分批到時任一批到就換日,未到的那批當天不在榜上。close_price 只取排名日當天收盤,沒有就 null。

Path Params:

名稱 類型 說明
investor_type string foreign、trust、dealer、total

Query Params:

名稱 類型 預設 說明
days int 1 統計天數,1-30
order string desc desc 買超、asc 賣超
limit int 20 回傳筆數,1-100

Response fields:

欄位 類型 說明
ticker string 股票代號
name string null
net_shares int 期間買賣超股數
close_price number null
net_value number null

GET /api/institutional/trust/daily/{ticker}

投信每日買賣超。參數同 foreign。

GET /api/institutional/trust/ranking

投信買賣超排行。參數同 foreign ranking。

GET /api/institutional/dealer/daily/{ticker}

自營商每日買賣超。參數同 foreign。

GET /api/institutional/dealer/ranking

自營商買賣超排行。參數同 foreign ranking。


GET /api/institutional/consensus/{ticker}

三大法人共識(看個股三大法人方向是否一致)。

Query Params:

名稱 類型 預設 說明
days int 20 統計天數

GET /api/institutional/consensus/all

全市場法人共識。三法人同買同賣、翻多翻空與連買連賣排行的預設日期同上(連買連賣看 institutional_streak.last_trade_date);close_price 只取排名日當天收盤,翻多翻空只納入排名日當天有法人列的股票。

Query Params:

名稱 類型 預設 說明
consensus_type string — 共識類型
limit int 50 回傳筆數

GET /api/institutional/breadth-indicator

市場寬度指標。

Query Params:

名稱 類型 預設 說明
indicators string — 指標類型(逗號分隔)
days int 60 天數

7b. 八大行庫/券商分點 Participant Flows

Prefix: /api/participant-flows

🔒 Auth: 需登入(Authorization: Bearer <access_token>)。無效 token 回 401;未帶 Authorization header 回 422。七個 endpoint 皆需登入。

鏡射 Cortex Data Hub 的八大行庫(government-bank)與券商分點(broker-branch)買賣明細,每日 01:00 TW 由排程同步(見 scripts/hub_participant_flows_sync.py)。所有金額/股數欄位在 wire 上一律為十進位字串或 null(Decimal/BIGINT 原樣轉字串,不做四捨五入)。可解析上市別的股票使用外部 ticker 格式(2330.TW/2330.TWO);上市別為 null 時使用裸代碼,例如八大行庫排行的指數及未收錄於 taiwan.stocks 的 Hub-only 股票代碼。後者的 name/listing_type 可為 null。以下兩個新 broker-branch endpoint 同樣要求登入;兩者皆為 GET,沒有 request body;完整請求/回應契約如下。

共同錯誤語意:

情境 HTTP 說明
未帶 / 無效 token 401 / 422 同上
ticker 無法解析(含缺 .TW/.TWO 後綴的台股裸碼)或非台股 ticker(如 AAPL) 422 全域 ExternalTickerInputError handler
ticker 不存在於 taiwan.stocks 404 Hub 含 ETF/權證代碼,未收錄進 taiwan.stocks 者一律 404(與排行 INNER JOIN 的取捨一致)
ticker 存在但查詢區間/窗口無列 200 回傳空陣列,不是 404
分點在窗口內無列(/broker-branch/branch/{participant_id}) 404 分點沒有名冊主檔,「有無列」是唯一存在性訊號
分點在窗口內查無此股任一列(/broker-branch/stock/{ticker}/branch/{participant_id}) 404 同上,範圍縮小到 ticker + 分點兩者同時對得上才有資料

GET /api/participant-flows/government-bank/stock/{ticker}

個股八大行庫買賣日序列:市場合計(total)與各行庫明細(banks)分開回傳。

Path Params:

名稱 類型 說明
ticker string 台股 ticker,2330.TW / 2330.TWO;裸碼或非台股回 422

Query Params:

名稱 類型 Required 預設 驗證 / 說明
days integer no 30 1..3650;以今日(Asia/Taipei)往回抓的日曆天數

Response 200:

欄位 類型 說明
ticker string 外部格式
total GovernmentBankTotalRow[] 市場合計,participant_kind=government_bank_total,依 trade_date 新到舊
banks GovernmentBankDetailRow[] 各行庫明細,participant_kind=government_bank,每日 1..8 家(只有當日有交易者才有列,不補滿 8 家)

GovernmentBankTotalRow:trade_date、buy_shares、sell_shares、net_shares(字串)、buy_value、sell_value、net_value(字串或 null)。 GovernmentBankDetailRow:以上欄位 + participant_id、participant_name。

{
  "ticker": "2330.TW",
  "total": [
    {
      "trade_date": "2026-09-01",
      "buy_shares": "1000000",
      "sell_shares": "400000",
      "net_shares": "600000",
      "buy_value": "50000000.50000000",
      "sell_value": "20000000.25000000",
      "net_value": "30000000.25000000"
    }
  ],
  "banks": [
    {
      "trade_date": "2026-09-01",
      "participant_id": "BANK1",
      "participant_name": "第一銀行",
      "buy_shares": "600000",
      "sell_shares": "100000",
      "net_shares": "500000",
      "buy_value": "30000000.00000000",
      "sell_value": "5000000.00000000",
      "net_value": "25000000.00000000"
    }
  ]
}

GET /api/participant-flows/government-bank/ranking

全市場八大行庫買超/賣超排行。INNER JOIN taiwan.stocks——不在 taiwan.stocks 的 ETF/權證代碼不進榜;limit 語意為「可 JOIN 到的前 N 名」,不是全體候選的前 N 名。buy 只收 net_value > 0 的個股、sell 只收 net_value < 0 的個股,兩榜互斥:同一檔不會同時出現在買超榜與賣超榜;net_value 為 0 或 null 的個股兩榜都不出現。net_value 相同時以 stock_code 升冪決定名次(排序次鍵),同分個股的排序在多次呼叫間穩定;listing_type 為 null 的代碼 ticker 退回裸代碼。若該裸代碼是純數字的個股代碼(防禦情境,現行資料不存在),它不能回填 /api/participant-flows/government-bank/stock/{ticker}——該端點要求 .TW/.TWO 後綴,會回 422;現行資料中唯一 listing_type 為 NULL 的是指數 ^TWII,指數代碼不需後綴,可直接查詢。

Query Params:

名稱 類型 Required 預設 驗證 / 說明
date date no 最新可得日 YYYY-MM-DD
side buy|sell no buy buy 只取 net_value > 0 依 DESC;sell 只取 net_value < 0 依 ASC
limit integer no 20 1..100

Response 200:

欄位 類型 說明
trade_date date | null 實際查詢日;無任何資料時為 null
side string 回傳 buy 或 sell
items GovernmentBankRankingItem[]

GovernmentBankRankingItem:rank(int,從 1 起)、stock_code(裸碼)、ticker(外部格式)、name(字串或 null——taiwan.stocks.name 可為空)、listing_type(字串或 null——指數等無上市別的代碼為 null)、net_shares、net_value(字串或 null)。

{
  "trade_date": "2026-09-01",
  "side": "buy",
  "items": [
    {
      "rank": 1,
      "stock_code": "2330",
      "ticker": "2330.TW",
      "name": "台積電",
      "listing_type": "tw",
      "net_shares": "600000",
      "net_value": "30000000.25000000"
    }
  ]
}

GET /api/participant-flows/broker-branch/stock/{ticker}

個股券商分點窗口彙總:回傳窗口內所有交易過該股的分點,每個分點一列(不再分買超/賣超兩榜,也不再有單日 date 參數)。

Path Params:

名稱 類型 說明
ticker string 同上

Query Params:

名稱 類型 Required 預設 驗證 / 說明
window integer yes 無 只接受 1 / 5 / 20 / 60(BROKER_BRANCH_WINDOWS,對應前端「今日/近5日/近20日/近60日」);其他值或未帶回 422
limit integer no 5000 1..5000(DEFAULT_BROKER_BRANCH_LIMIT / MAX_BROKER_BRANCH_LIMIT,沿用 stocks.py list endpoint 的上限);全市場約 820 個分點,一頁即取全量;截斷發生在 ORDER BY net_value DESC NULLS LAST 之後,limit 設小時只會留下淨買超最多的前幾名

窗口語意: window 是整個券商分點 dataset 最近 N 個 distinct trade_date(dataset-wide,與三大法人 amount ranking 相同的窗口建構),不是該股自己的最近 N 天,也不是 N 個日曆天。因此某股若在最新市場日無列,window=1 回 200 + items: [](即使它有更早的列)。回應的 start_date / end_date / actual_days 描述這個 dataset 窗口;資料不足 N 天時 actual_days < window。

加總語意: 六個數量欄位為窗口內 SUM。窗口內任一日股數非零但金額為 null 時,該側 *_value 與 *_avg_price 回 null(股數仍加總)——買/賣兩側各自判斷,股數為 0 的日子不影響該側;淨額則是該日買或賣任一側股數非零且 net_value 為 null 時回 null(淨股數為 0 不代表無交易),只有買賣兩側股數皆為 0 的日子不影響。net_value 為 null 的列被 ORDER BY net_value DESC NULLS LAST, participant_id 排到最後。buy_avg_price = SUM(buy_value) / NULLIF(SUM(buy_shares), 0)、sell_avg_price 同理,語意是窗口 VWAP(買進股數為 0 → null),window=1 時也可能與 hub 儲存的當日均價欄位不同。participant_name 取窗口內最新一天的名稱。

Response 200:

欄位 類型 說明
ticker string 外部格式
window integer 回聲請求的 window
start_date / end_date date | null dataset 窗口的最早/最晚交易日;無交易日時為 null
actual_days integer 窗口實際涵蓋的交易日數(≤ window);無交易日時為 0
items BrokerBranchFlowItem[] 每個分點一列,依 net_value 由大到小、NULL 最後

BrokerBranchFlowItem:participant_id、participant_name、buy_shares、sell_shares、net_shares、buy_value、sell_value、net_value、buy_avg_price、sell_avg_price(數值欄位一律字串或 null)。

{
  "ticker": "2330.TW",
  "window": 5,
  "start_date": "1902-01-06",
  "end_date": "1902-01-07",
  "actual_days": 2,
  "items": [
    {
      "participant_id": "9A00-9A01",
      "participant_name": "永豐金-台北",
      "buy_shares": "2400000",
      "sell_shares": "800000",
      "net_shares": "1600000",
      "buy_value": "144000000.00000000",
      "sell_value": "47200000.00000000",
      "net_value": "96800000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "59.0000000000000000"
    },
    {
      "participant_id": "1234-5678",
      "participant_name": "元大-土城",
      "buy_shares": "600000",
      "sell_shares": "1800000",
      "net_shares": "-1200000",
      "buy_value": "36300000.00000000",
      "sell_value": "107100000.00000000",
      "net_value": "-70800000.00000000",
      "buy_avg_price": "60.5000000000000000",
      "sell_avg_price": "59.5000000000000000"
    }
  ]
}

GET /api/participant-flows/broker-branch/branch/{participant_id}

單一券商分點的窗口彙總:回傳該分點在窗口內買賣過的所有個股,每檔一列(GROUP BY stock_code)。窗口、加總、VWAP、排序語意與上一支相同(排序次鍵為 stock_code)。

Path Params:

名稱 類型 說明
participant_id string 分點代號(如 9A00-9A01);長度上限 80(taiwan.hub_participant_flows.participant_id 為 VARCHAR(80)),超過回 422

Query Params: 同上一支(window 必填、limit 預設/上限 5000;全市場約 2,001 檔個股;截斷發生在 ORDER BY net_value DESC NULLS LAST 之後,limit 設小時只會留下淨買超最多的前幾名)。

404 的理由(與個股 endpoint 的不對稱): 個股有 taiwan.stocks 主檔可以證明「股票存在但窗口內無資料」,所以個股 endpoint 用 200 + items: [];分點沒有名冊主檔,「窗口內有無列」是唯一的存在性訊號,因此分點在窗口內無列一律回 404。

Response 200:

欄位 類型 說明
participant_id string 回聲 path 參數
participant_name string 窗口內最新一天的分點名稱
window / start_date / end_date / actual_days 同上一支
items BrokerBranchStockFlowItem[] 每檔個股一列

BrokerBranchStockFlowItem:stock_code、ticker、name、listing_type + 上一支的八個數值欄位。name / listing_type 來自 LEFT JOIN taiwan.stocks,hub-only 的 ETF/權證代碼兩者皆為 null、ticker 退回裸代碼。

{
  "participant_id": "9A00-9A01",
  "participant_name": "永豐金-台北",
  "window": 5,
  "start_date": "1902-01-06",
  "end_date": "1902-01-07",
  "actual_days": 2,
  "items": [
    {
      "stock_code": "2330",
      "ticker": "2330.TW",
      "name": "台積電",
      "listing_type": "tw",
      "buy_shares": "2400000",
      "sell_shares": "800000",
      "net_shares": "1600000",
      "buy_value": "144000000.00000000",
      "sell_value": "47200000.00000000",
      "net_value": "96800000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "59.0000000000000000"
    },
    {
      "stock_code": "00940",
      "ticker": "00940",
      "name": null,
      "listing_type": null,
      "buy_shares": "200000",
      "sell_shares": "0",
      "net_shares": "200000",
      "buy_value": "1960000.00000000",
      "sell_value": "0E-8",
      "net_value": "1960000.00000000",
      "buy_avg_price": "9.8000000000000000",
      "sell_avg_price": null
    }
  ]
}

GET /api/participant-flows/broker-branch/stock/{ticker}/branch/{participant_id}

單一券商分點在一檔個股上的逐日明細:給定 ticker + 分點,回窗口內每個交易日一列(不是像 A/B 那樣先彙總成一列)。

Path Params:

名稱 類型 說明
ticker string 同上
participant_id string 分點代號(如 9A00-9A01);長度上限 80(VARCHAR(80)),超過回 422

Query Params:

名稱 類型 Required 預設 驗證 / 說明
days integer yes 無 1..116(MAX_BROKER_BRANCH_DAILY_DAYS,由 BROKER_BRANCH_RETENTION_DAYS 導出);超出範圍回 422。實際拿得到的天數見下方「保留期上限」

⚠️ 與 government-bank/stock 的 days 語意不同:那支的 days 是選填、預設 30、以今日往回抓的日曆天;這支的 days 是必填、無預設值的交易日筆數(dataset-wide 最近 N 個 distinct trade_date,與 A/B 的 window 同一種窗口建構,只是不限 1/5/20/60,可帶任意整數)。同一個 router 下兩個同名參數語意不同,串接時務必分清楚。

保留期上限: 券商分點列每日由排程 purge 掉超過 BROKER_BRANCH_RETENTION_DAYS(116 個日曆天)的資料,因此 actual_days 實際上限落在 116 個日曆天內的交易日數(約 84 個交易日)、start_date 不會早於約 116 天前。days 的 schema 範圍是 1..116:帶 85..116 是合法請求(actual_days 會小於 days),帶 117 以上回 422——days=120 是參數錯誤,不會回一份被截短的資料。

逐日與 null 語意: 窗口內每個交易日各回一列,ORDER BY trade_date DESC。該分點當天沒有交易此股時,八個數量欄位一律回 null——判斷「有無交易」看 buy_shares 是否為 null(底層欄位為 BIGINT NOT NULL,真實列必為非 null),刻意不提供額外的 traded boolean。buy_avg_price/sell_avg_price 是 value / NULLIF(shares, 0) 的單日均價,公式與 A/B 窗口 VWAP 相同、但逐日各自計算不先加總。當天股數非零但金額為 null(unpriced day)時,該側 *_value/*_avg_price 回 null、*_shares 仍是真實股數,A/B 窗口加總需要的 bool_or 防護在這裡不需要——每列 SQL NULL 隨算式自然傳遞即可得到正確結果。無 limit 參數,列數即 min(days, 窗口內可得交易日數)。

404: 該分點在窗口內查無此股任一列時回 404(與 /broker-branch/branch/{participant_id} 的 404 判斷方式相同,範圍更窄:ticker + participant_id 兩者同時對得上才算有資料);換句話說,只要回 200,items 內至少有一列的八個數量欄位非全 null,start_date/end_date 也一定非 null。

Response 200:

欄位 類型 說明
ticker string 外部格式
participant_id string 回聲 path 參數
participant_name string 窗口內最新一天的分點名稱
days integer 回聲請求的 days
start_date / end_date date dataset 窗口的最早/最晚交易日
actual_days integer 窗口實際涵蓋的交易日數(= items.length)
items array 每個窗口交易日一列,依 trade_date 由新到舊

items[] 每列:trade_date(date)+ 上兩支相同的八個數量欄位 buy_shares/sell_shares/net_shares/buy_value/sell_value/net_value/buy_avg_price/sell_avg_price(一律字串或 null)。

{
  "ticker": "2330.TW",
  "participant_id": "1480",
  "participant_name": "美商高盛",
  "days": 5,
  "start_date": "2026-09-03",
  "end_date": "2026-09-09",
  "actual_days": 5,
  "items": [
    {
      "trade_date": "2026-09-09",
      "buy_shares": "1200000",
      "sell_shares": "300000",
      "net_shares": "900000",
      "buy_value": "72000000.00000000",
      "sell_value": "18150000.00000000",
      "net_value": "53850000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "60.5000000000000000"
    },
    {
      "trade_date": "2026-09-08",
      "buy_shares": null,
      "sell_shares": null,
      "net_shares": null,
      "buy_value": null,
      "sell_value": null,
      "net_value": null,
      "buy_avg_price": null,
      "sell_avg_price": null
    },
    {
      "trade_date": "2026-09-07",
      "buy_shares": "800000",
      "sell_shares": "950000",
      "net_shares": "-150000",
      "buy_value": "48400000.00000000",
      "sell_value": "58900000.00000000",
      "net_value": "-10500000.00000000",
      "buy_avg_price": "60.5000000000000000",
      "sell_avg_price": "62.0000000000000000"
    },
    {
      "trade_date": "2026-09-04",
      "buy_shares": "500000",
      "sell_shares": "200000",
      "net_shares": "300000",
      "buy_value": null,
      "sell_value": "12000000.00000000",
      "net_value": null,
      "buy_avg_price": null,
      "sell_avg_price": "60.0000000000000000"
    },
    {
      "trade_date": "2026-09-03",
      "buy_shares": "600000",
      "sell_shares": "600000",
      "net_shares": "0",
      "buy_value": "36600000.00000000",
      "sell_value": "36300000.00000000",
      "net_value": "300000.00000000",
      "buy_avg_price": "61.0000000000000000",
      "sell_avg_price": "60.5000000000000000"
    }
  ]
}

2026-09-08 是「當天沒有交易此股」的示例(八個數量欄位皆 null);2026-09-04 是「unpriced day」示例(buy_shares 非 null 但當日買方金額不可得,buy_value/buy_avg_price/net_value 隨之為 null,sell 側不受影響)。start_date/end_date/actual_days 與 items.length(5)在此範例中一致,示範本 endpoint 不壓縮清單、每個窗口交易日都補出一列的行為;同一組範例見 docs/API_SPEC.md。

GET /api/participant-flows/broker-branch/branches

搜尋最新資料日出現過的分點目錄。只有該資料日有流量列的分點會列出;每筆 last_trade_date 都等於 catalog_as_of,不表示系統保存分點的完整歷史名冊。 目錄按代碼升冪;q 修剪首尾空白後,對代碼/名稱做不分大小寫的子字串比對, limit 在比對後套用,沒有游標分頁。無符合項目回 200 與空 items。

Query Params:

名稱 類型 Required 預設 驗證 / 說明
q string no "" 最長 240 字元;不可為 null
limit integer no 20 1..100

Response 200: application/json。

欄位 類型 Nullable 說明
catalog_as_of date no 最新有 broker-branch 資料的交易日
total integer no 完整比對數,不受 limit 截取影響
items BrokerBranchDirectoryEntry[] no 代碼升冪,最多 limit 筆
items[].participant_id string no 分點代碼
items[].participant_name string no 最新日期分點名稱
items[].last_trade_date date no 等於 catalog_as_of
{
  "catalog_as_of": "2026-09-30",
  "total": 1,
  "items": [
    {"participant_id": "9268", "participant_name": "凱基台北", "last_trade_date": "2026-09-30"}
  ]
}

GET /api/participant-flows/broker-branch/analysis

回傳分點在固定的最新資料截止日、最近 N 個 hub_participant_flows distinct 資料日期 內的所有股票彙總、跨分點主力排名、持倉成本估算及相鄰資料日期反轉候選。participant_id 是 query 參數, 可使用包含斜線等字元的 ID,但需依 URL query 編碼規則編碼。完整分析回應範例見 Cortex Investment Agent API 文件。

Query Params:

名稱 類型 Required 預設 驗證 / 說明
participant_id string yes — 不可 null,長度 1..80
window integer no 1 只接受 1、5、20、60 個資料日期

日期窗口依 hub_participant_flows 的 taiwan.broker_branch_buy_sell 資料集, 由新到舊選取全體分點共同的最近 N 個 distinct 資料日期, 再取其前一筆較舊資料日期(若存在)供隔日沖配對;前置日期不納入窗口彙總或排名, 且不依賴官方交易日曆。若資料歷史短於請求窗口,仍以可用資料日期計算, actual_days < window 且 coverage.status 為 partial,成本與排名仍依實際選出的 日期計算。missing_trade_dates 保留相容性並固定為 []。相鄰資料日期間隔可能來自 Hub 缺日或市場休市,系統無法區分,也不推斷官方交易日或資料完整性。全體資料集為空 時回 503;已知分點沒有窗口資料仍回 200 與空 items。

Response 200: application/json。已知分點在視窗內沒有交易回 items: []; 未知分點回 404。列表依 net_value DESC NULLS LAST, stock_code 排序,沒有分頁。

欄位 類型 Nullable 說明
participant_id string no 回聲 query 參數
participant_name string no 視窗內名稱;無視窗列時用該分點最近已存名稱
window integer no 請求的資料日期數;實際天數見 actual_days
start_date / end_date date no 實際選取資料日期的起訖日
actual_days integer no 實際選取的窗口資料日期數,最多為 window;歷史較短時可能更少
updated_at datetime yes 讀取窗口及前置日分點資料的最新 hub_fetched_at,ISO 8601
coverage object no 覆蓋狀態,欄位見下表
items BrokerBranchAnalysisItem[] no 分點在窗口內交易過的股票

coverage:

欄位 類型 Nullable 說明
status string no 前置資料日不存在或個股不可估時為 partial,其餘為 unverified;不代表來源完整
expected_days integer no 等於 window
required_session_count integer no window + 1,包含隔日沖所需前置日
available_session_count integer no 實際選取窗口日期數,加上前置資料日期存在時的 1;不證明來源全量完整
missing_trade_dates date[] no 相容欄位,固定為 [];此 API 不推斷官方缺漏交易日
predecessor_complete boolean no 第 window + 1 個資料日期是否存在;歷史少於或等於 window 日時為 false
partial_instrument_count integer no 持倉或隔日沖資料不完整的股票數

items[] 先含下列股票與窗口欄位,這八個數量、金額與均價欄位均為十進位 字串或 null:stock_code(string)、ticker(string)、name(string|null)、 listing_type(string|null)、buy_shares、sell_shares、net_shares、 buy_value、sell_value、net_value、buy_avg_price、sell_avg_price。 name/listing_type 可空,因股票主檔採 LEFT JOIN;金額以新臺幣元、均價以元/股。

此外每筆包含:

欄位 類型 Nullable 說明
main_force.metric string no 固定為 net_shares
main_force.buy_rank / sell_rank integer yes 全分點同股同期間 competition rank;中性方向無對應排名。排名依實際選出的資料日期計算;API 回完整排名,不設前三名門檻
holding_estimate.method string no 固定為 window_moving_average_v1
holding_estimate.status string no estimated、no_modeled_position 或 unavailable
holding_estimate.shares decimal string yes 模型剩餘股數
holding_estimate.avg_price decimal string yes 模型持倉均價,元/股
holding_estimate.reason string yes 不可估原因為 missing_buy_value、unsupported_corporate_action;成功估算或模型無持倉時為 opening_position_unknown_assumed_zero
overnight.method string no 固定為 adjacent_net_flow_v1
overnight.status string no candidate、none 或 insufficient_data
overnight.event_count / evaluated_pair_count / skipped_pair_count integer no 候選事件數、已評估配對數、略過配對數
overnight.total_overlap_shares decimal string yes 已評估配對的 overlap 股數總和
overnight.latest_event object yes 最新候選事件;沒有事件時為 null

overnight.latest_event 非 null 時:

欄位 類型 Nullable 說明
buy_date / sell_date date no 配對的前一與次一資料日期
previous_net_buy_shares / next_net_sell_shares decimal string yes 前一日正淨買、次一日正淨賣股數
overlap_shares / sellback_ratio decimal string yes 配對重疊股數及 overlap ÷ 前日正淨買股數

持倉成本由窗口期初零股數/零成本開始,逐日先買後賣更新移動平均成本;日內 成交先後未知,此順序是假設。賣出最多扣至模型股數歸零。estimated 不是實際 帳戶庫存/成本;無持倉時 no_modeled_position 的 shares 為 "0"、均價為 null。缺買進金額時為 unavailable。在 (start_date, end_date] 內,三類來源 都會使相關股票保守地標記 unavailable:股票股利/除權事件、已 暫存的非現金公司行動,以及啟用的價格調整因子區段。已暫存事件不依批次的 coverage 狀態排除;incomplete/failed 代表整批未完成,不代表已取得的事件 不存在。暫存證據不可變且可能保留 已取消 run 的紀錄,因此估算可能在事件實際生效狀態不確定時仍停用;reason 指出買進金額缺漏或公司行動。

隔日沖依所選資料日期序列中的相鄰日期,以前一日正淨買與次一日正淨賣的較小者計算疑似重疊量, 不代表同一投資人或確定隔日沖。狀態 candidate 表示至少一個配對有重疊;無候選 且沒有前置資料日為 insufficient_data,第一組配對略過;有前置資料日且無候選為 none。若資料日期有 間隔,仍只比較序列中相鄰的資料日期;間隔可能來自 Hub 缺日或市場休市,系統無法 區分,也不推斷官方交易日或市場統計完整性。配對事件的 日期、淨買/淨賣量、overlap、sellback ratio 均為 date 或十進位字串。當 evaluated_pair_count=0 時 total_overlap_shares=null;至少一組已評估時為十進位 字串,即使沒有重疊也回 "0"。

分析回應快取沒有固定時間 TTL,依持久資料版本失效:目錄依 hub_participant_flows;分析依 hub_participant_flows、stocks、 taiwan_corporate_action_evidence、stock_adjustment_factor_projection,並以分點、 窗口及實際選出的日期序列(含存在時的前置日)區分結果。60 個資料日期的冷查詢曾需約 60–93 秒,Redis 可加速重複查詢但不會 降低冷查詢成本。

錯誤:

狀況 HTTP Body
缺少 Authorization header 422 FastAPI 原生 {"detail":[...]} 參數驗證格式
無效/過期 JWT 401 {"error":{"code":"...","message":"...","details":{}}}
q/limit/participant_id 或 FastAPI 型別驗證錯誤 422 FastAPI 原生 {"detail":[...]}
window 不在 1/5/20/60 422 全域錯誤 envelope;error.code 與 error.message 均為 window must be one of 1, 5, 20, 60
目錄查無符合分點 200 total: 0, items: []
全市場目前無分點資料 503 HTTPException 全域 envelope,error.code/message 為 Broker-branch data unavailable
分析 participant_id 未知 404 HTTPException 全域 envelope,error.code/message 為 Unknown participant <id>
分析無任何全市場分點資料 503 HTTPException 全域 envelope,error.code/message 為 Broker-branch data unavailable

HTTPException 的全域 envelope 形狀見本文件「通用 Response 格式」;FastAPI 原生參數錯誤仍為 detail[]。


8. 融資融券 Margin

Prefix: /api/margin

GET /api/margin/stock/{ticker}

取得融資融券餘額資料。

Query Params:

名稱 類型 預設 說明
days int 30 天數(1-365)

Response — MarginData[]:

[
  {
    "ticker": "2330.TW",
    "trade_date": "2024-03-15",
    "margin_balance": 15000,
    "short_balance": 2000,
    "margin_buy": 500,
    "margin_sell": 300,
    "short_buy": 100,
    "short_sell": 150,
    "margin_previous": 14800,
    "short_previous": 1950,
    "source_market": "twse"
  }
]

GET /api/margin/ranking

融資融券標的排行榜。排名日為 margin_balance 不晚於官方交易日的最新一日(上市、上櫃分批到時任一批到就換日),依指定指標排序回傳前 N 檔;close_price 只取排名日當天收盤,沒有就 null。

Query Params:

名稱 類型 預設 說明
metric string margin_change 排序維度,四選一:margin_change(融資增減)/ short_change(融券增減)/ margin_balance(融資餘額)/ short_balance(融券餘額)
order string desc desc=增加/餘額最多在前;asc=相反
limit int 20 回傳筆數(1-100)

*_change 為日對日差(餘額 − 前日餘額)。前日資料缺漏者該指標為 null 並排在最後。無任何融資融券資料時回傳空陣列 []。

Response — MarginRankingStock[]:

[
  {
    "ticker": "00631L.TW",
    "name": "元大台灣50正2",
    "trade_date": "2026-07-17",
    "margin_balance": 275683,
    "margin_previous": 244400,
    "margin_change": 31283,
    "short_balance": 13864,
    "short_previous": 16884,
    "short_change": -3020,
    "close_price": 32.17
  }
]

Errors: metric / order 非白名單值或 limit 越界 → 422。


8B. 台股逐檔當沖統計 Day Trading

Prefix: /api/day-trading。公開唯讀;資料由排程寫入 DB,API request 不即時呼叫交易所。

GET /api/day-trading/stocks/{ticker}

取得單一上市/上櫃股票最近 N 個有資料交易日的當沖統計。SQL 先依日期降冪取最近 N 筆,response 再依日期升冪,供 cortex-web 趨勢圖直接使用。

位置 名稱 類型 必填 Nullable 預設 說明
path ticker string 是 否 — canonical ticker,例如 2330.TW / 5483.TWO
query days integer 否 否 30 1–1000
query final_only boolean 否 否 false true 時只回 T+2 final rows
{
  "ticker": "2330.TW",
  "name": "台積電",
  "market": "TWSE",
  "total": 1,
  "items": [
    {
      "trade_date": "2026-08-11",
      "day_trade_volume": 1234567,
      "total_volume": 18247582,
      "day_trade_ratio": 6.77,
      "day_trade_buy_value": 1234567890,
      "day_trade_sell_value": 1234600000,
      "revision_stage": 0,
      "is_final": false,
      "updated_at": "2026-08-11T23:01:00+08:00"
    }
  ]
}

name、total_volume、day_trade_ratio 可為 null;其他 item 欄位必有值。股票不存在回 404 {"error":{"code":"Stock not found: ...","message":"Stock not found: ...","details":{}}};股票存在但無當沖資料回 200、total=0、items=[];query 越界或 boolean 格式錯誤回 FastAPI 422 detail[]。

GET /api/day-trading/rankings/latest

首頁熱門榜可依當沖成交股數或當沖比率排序;兩者固定降冪並以 ticker ASC 打破同值,不接受任意 metric/order SQL 字串。回應同時提供同一檔股票相對共同前一交易日的股數百分比與比率百分點差。

位置 名稱 類型 必填 Nullable 預設 說明
query market all \| TWSE \| TPEX 否 否 all trade_date 為所選市場不晚於官方交易日的最新一日;all 時兩市場須齊才排名,只到一邊回該日 pending_source 與 sources
query metric day_trade_volume \| day_trade_ratio 否 否 day_trade_volume 排序指標;response ranking_metric 回傳實際值
query limit integer 否 否 10 1–100
query security_type common \| all 否 否 common 首頁預設排除 ETF/ETN
query final_only boolean 否 否 false 只使用 final snapshot
{
  "trade_date": "2026-08-11",
  "previous_trade_date": "2026-08-10",
  "revision_stage": 0,
  "is_final": false,
  "ranking_metric": "day_trade_volume",
  "market": "all",
  "security_type": "common",
  "total": 1,
  "items": [
    {
      "rank": 1,
      "ticker": "2330.TW",
      "name": "台積電",
      "market": "TWSE",
      "security_type": "common",
      "day_trade_volume": 1234567,
      "day_trade_volume_change_pct": 12.34,
      "total_volume": 18247582,
      "day_trade_ratio": 6.77,
      "day_trade_ratio_change_pp": -0.56,
      "day_trade_buy_value": 1234567890,
      "day_trade_sell_value": 1234600000,
      "revision_stage": 0,
      "is_final": false,
      "updated_at": "2026-08-11T23:01:00+08:00"
    }
  ]
}

previous_trade_date、day_trade_volume_change_pct、day_trade_ratio_change_pp 都是 response 必有但值可為 null 的欄位。market=all 的當日與前日皆為 TWSE/TPEx 同時有資料的最近兩個共同日期;指定市場時只看該市場。final_only=true 同時套用兩日。前日個股缺列時不再向前回溯,該檔兩個差異欄位皆為 null。

  • day_trade_volume_change_pct = (current - previous) / previous * 100,round 2;前日列缺失或前日股數為 0 時為 null。當日股數為 0 且前日大於 0 時為 -100.0。
  • day_trade_ratio_change_pp = (current day_trade_volume / current OHLC volume - previous day_trade_volume / previous OHLC volume) * 100,以未先四捨五入的 raw fractions 相減後 round 2;任一日 OHLC volume 缺失/0 或前日列缺失時為 null。

沒有符合條件的完整日期時回 200,trade_date / previous_trade_date / revision_stage 為 null、is_final=false、items=[],且 ranking_metric 仍回傳請求值。day_trade_ratio 使用同 ticker、同交易日 OHLC volume;volume 缺值或 0 時,一般成交股數榜的 ratio 欄位為 null,ratio 排行榜則排除該標的。未知 enum 或 limit 越界回 422 detail[]。


9. 股權分散表 Ownership

Prefix: /api/ownership

內部儲存重構(2026-06,zero API change):股權寬欄位(15 級距 ratio/change) 自每日重複寫入 daily_chip_snapshot 改為一週一列的 ownership_weekly_snapshot 內部表,相關 endpoint(本節、/api/chip/summary/{ticker}、 /api/recaps/chip/daily/{requested_date}、/api/signals/stock/{ticker})的 request/response 完全不變。

GET /api/ownership/stock/{ticker}

取得股權分散表。

Query Params:

名稱 類型 預設 說明
trade_date string — 指定日期 YYYY-MM-DD
weeks int — 近 N 週(1-104)

Response — OwnershipDistribution[]:

[
  {
    "ticker": "2330.TW",
    "trade_date": "2024-03-15",
    "holder_level": "1-999",
    "holder_count": 350000,
    "shares": 500000000,
    "share_ratio": 5.2
  }
]

10. 基本面 Fundamentals

Prefix: /api/fundamentals

GET /api/fundamentals/monthly-revenue/{ticker}

月營收。

Query Params:

名稱 類型 預設 說明
months int 12 月數(1-120)

Response — MonthlyRevenue[]:

[
  {
    "ticker": "2330.TW",
    "data_year_month": 202403,
    "report_date": "2024-04-10",
    "revenue_current": 2100000000,
    "revenue_prev_month": 1950000000,
    "revenue_prev_year_month": 1800000000,
    "mom_change_pct": 7.69,
    "yoy_change_pct": 16.67,
    "ytd_revenue": 6000000000,
    "ytd_revenue_prev_year": 5200000000,
    "ytd_yoy_change_pct": 15.38,
    "source_market": "twse"
  }
]

GET /api/fundamentals/valuation/{ticker}

估值指標(PE/PB/殖利率)。

Query Params:

名稱 類型 預設 說明
days int 60 天數(1-365)

Response — ValuationMetric[]:

[
  {
    "ticker": "2330.TW",
    "trade_date": "2024-03-15",
    "pe_ratio": 25.3,
    "pb_ratio": 6.8,
    "dividend_yield": 1.8,
    "dividend_per_share": 15.0,
    "source_market": "twse"
  }
]

GET /api/fundamentals/quarterly/{ticker}

季報數據。

Query Params:

名稱 類型 預設 說明
years int 5 年數(1-10)

Response — QuarterlyReport[]:

[
  {
    "ticker": "2330.TW",
    "report_year": 2024,
    "report_quarter": 1,
    "report_date": "2024-05-15",
    "eps_basic": 9.56,
    "eps_diluted": 9.52,
    "revenue": 5926000000,
    "net_income": 2254000000,
    "source": "mops"
  }
]

GET /api/fundamentals/dividend-events/{ticker}

個股除權息時間軸(歷史 + 即將到來),除息(現金)與除權(配股)各自包裹、各有填息/填權狀態與推導現金殖利率。 資料來源:TWSE TWT48U_ALL + TPEx tpex_exright_prepost 除權除息預告表(whole-market,免費)。

Query Params:

名稱 類型 預設 說明
days int config lookback_days(730) 回溯天數(1-3650);未來已排定事件一律納入

Response — DividendEvent[](API 值皆為英文 enum;繁中顯示由前端 mapping):

type:cash(除息)/ stock(除權)/ both(除權息)。 fill_status(ex_dividend 與 ex_rights 各一):not_due(未到)/ filled(已填,最新收盤 ≥ 除權息前收盤)/ partial(部分填,介於參考價與前收盤)/ discount(貼,低於參考價)/ unknown(無可比價)。 reference_price(除權息參考價)、各 fill_status、cash_yield 皆由本地 OHLC 即時推導(預告表來源無價格欄位)。ex_dividend 於純除權時為 null、ex_rights 於純除息時為 null。

reference_price 採 TWSE 官方全公式 (前收 - 現金股利 + 認股比例 × 認購價) / (1 + 配股率 + 認股比例)。

現金增資(rights issue): 預告表的「除權/除權息」同時涵蓋配股與現金增資。ex_rights 額外帶 subscription_ratio(認股比例)與 subscription_price(認購價格);subscription_ratio > 0 即代表含現金增資成分。純現金增資指紋為 type="stock" 且 stock_dividend=0 且 subscription_ratio>0(前端應顯示「現金增資」而非配股)。當認購價尚未公告時,無法計算參考價,reference_price 為 null。

[
  {
    "ex_date": "2023-06-15",
    "type": "both",
    "reference_price": 97.0,
    "ex_dividend": { "cash_dividend": 0.5, "cash_yield": 0.52, "fill_status": "filled" },
    "ex_rights": { "stock_dividend": 0.11, "subscription_ratio": null, "subscription_price": null, "fill_status": "filled" },
    "source": "TWSE"
  },
  {
    "ex_date": "2026-06-18",
    "type": "stock",
    "reference_price": 19.45,
    "ex_dividend": null,
    "ex_rights": { "stock_dividend": 0.0, "subscription_ratio": 0.2468, "subscription_price": 17.2, "fill_status": "not_due" },
    "source": "TPEx"
  }
]

回傳 404 當該 ticker 無除權息資料。


GET /api/fundamentals/dividend-calendar

全市場除權息行事曆:列出 [on, on + window] 區間內即將除權息的個股。

Query Params:

名稱 類型 預設 說明
on date 今日 區間起始日(YYYY-MM-DD)
window int config calendar_window_days(7) 往後天數(1-90)

Response — DividendCalendarEntry[](v2 enum shape,同 DividendEvent 外加 ticker/name;無事件時回傳空陣列):

[
  {
    "ticker": "2330.TW",
    "name": "台積電",
    "ex_date": "2024-06-13",
    "type": "cash",
    "reference_price": 796.0,
    "ex_dividend": { "cash_dividend": 4.0, "cash_yield": 0.5, "fill_status": "filled" },
    "ex_rights": null,
    "source": "TWSE"
  }
]

11. 策略訊號 Signals

Prefix: /api/signals

GET /api/signals/latest

取得最新策略訊號。

Query Params:

名稱 類型 必填 說明
direction string 否 依 primary signal 的實際 state 篩選:bullish / bearish
date string 否 指定日期 YYYY-MM-DD
ticker string 否 指定個股
view string 否 summary(預設)/ full
limit int 否 排序(confidence 降冪)後最多回傳 N 筆;1 ≤ N ≤ signals.latest.max_limit(core.yaml,預設 3000)

⚠️ 預設為 view=summary:巢狀欄位(structured_signals / signal_summary / ta_index_groups / signals / events / stories / relations)一律以空值([] / {})回傳,全市場回應約 2-3MB。需要完整巢狀 payload 必須顯式帶 view=full(全市場約 240MB,慎用)。此預設自 issue #568 起生效——舊呼叫方若依賴巢狀欄位,會拿到空值而非錯誤。

方向語意:頂層 direction、direction query filter,以及 /api/signals/chip-highlights、/api/signals/financial-highlights 的 頂層 bias 都使用 primary signal 的實際 state。巢狀 signals[*].direction 則是 signal definition 支援的方向集合,可能是 bullish/bearish/neutral 等複合值;該訊號在本筆資料的實際狀態請讀 signals[*].state。

SES 支撐壓力(#1746):view=full 的 signals[signal_code=ta_support_resistance_risk] 使用完整歷史中日期最近、 左右各 5 根嚴格確認的前低/前高,不設到期。support_break 僅在收盤嚴格 低於前低時觸發;support_nearby 為前低上方 0–3%,resistance_nearby 為前高下方 0–3%,跌破支撐優先,其次為接近壓力。無有效前低/前高時對應 事件不成立。決策使用 adjusted 價格,計算完成後 signal evidence 投影回該 快照的顯示尺度;事件名稱、陣列結構、型別、status code、query 與驗證錯誤 均不變。signals[*].index_evidence.supporting.story_swing_high_date 是新增的 可缺值前高日期 evidence,與既有 evidence entry 結構相同。

Phase B 也將頂層 snapshot 支撐/停損 producer 切換為已確認前低/前高; 既有歷史列須完成選定範圍補算才符合新語意。完整規則、各階段狀態與 targeted 補算見 前低/前高支撐壓力。

支撐族雙基準(issue #1253,migration 125):支撐/壓力欄位同時提供兩種價格 基準,兩者都正確、回答的是不同問題,呼叫方依用途擇一,不可混用:

基準 欄位形態 語意 適用
還原(adjusted) 既有欄名(major_support、low_20d_min、distance_to_support_*、structure_stop…) 以還原權息序列量測;除息後隨價格一併下修 停損/決策——不會把已配發的股利算進支撐
名目(raw) _raw 後綴孿生欄(major_support_raw…) 以實際成交價序列量測 市場記憶——「買方上次進場的價位」

major_support/major_resistance 是各自價格序列完整歷史中日期最近、左右各 5 根嚴格確認的前低/前高,不設到期;raw 與 adjusted 獨立確認,pivot 日期也可 不同。structure_stop/invalidation_level 等於 adjusted 前低,沒有前低即為 null,不再用 ATR 距離生成替代停損。structure_stop_raw 等於 raw 前低。

20/60 根盤中低點及距離欄保留既有滾動定義,分子分母必為同一基準;它們不再 代表 major support。atr_stop_distance 仍是 ATR × 2 的獨立診斷值;它與 invalidation_level 均沒有 raw 孿生欄。舊歷史列與尚未補算的 raw 欄 必須依補算狀態解讀,不能僅依欄名假設已切換。

Response — SignalResponse[]:

[
  {
    "ticker": "2330.TW",
    "name": "台積電",
    "signal_date": "2024-03-15",
    "signal_type": "ta_ma_break_event",
    "strategy_type": "short_term_long",
    "direction": "bullish",
    "description": "RSI 超賣反彈訊號",
    "strength": 0.85,
    "price": 850.0,
    "change_pct": 1.8,
    "bias": "bullish",
    "confidence": 0.72,
    "rsi_value": 28.5,
    "rsi_status": "oversold",
    "macd_cross_signal": "golden_cross",
    "macd_divergence": null,
    "kdj_signal": "low_passivization",
    "kdj_cross_signal": "golden_cross",
    "volume_ratio": 1.35,
    "volume_status": "above_average",
    "ma_alignment": "bullish",
    "cti_score": 0.65,
    "bb_status": "near_lower",
    "major_support": 830.0,
    "major_resistance": 870.0,
    "major_support_raw": 812.0,
    "major_resistance_raw": 851.0,
    "patterns": ["hammer", "morning_star"],
    "pattern_reliability": "high",
    "atr_stop_distance": 25.0,
    "invalidation_level": 825.0,
    "signals": [
      {
        "id": "technical.signal.ta_ma_break_event",
        "signal_code": "ta_ma_break_event",
        "domain": "technical",
        "state": "bullish",
        "direction": "bullish/bearish",
        "score": 0.72,
        "strength": 0.72,
        "name": "均線穿越事件",
        "description": "",
        "display": { "title": "均線穿越事件" },
        "readiness": { "status": "ready", "missing_indexes": [] },
        "index_evidence": {
          "primary": {
            "break_above_ma20": {
              "value": true,
              "present": true,
              "name": "站上月線(20MA)",
              "description": "",
              "display": { "title": "站上月線(20MA)" }
            }
          },
          "supporting": {},
          "risk": {},
          "gate": {}
        }
      }
    ],
    "events": [
      {
        "id": "technical.event.ma_month_reclaim",
        "event_code": "ma_month_reclaim",
        "signal_code": "ta_ma_break_event",
        "domain": "technical",
        "state": "active",
        "direction": "bullish",
        "score": 0.76,
        "strength": 0.76,
        "confidence": 0.76,
        "name": "站回月線(MA20)",
        "description": "",
        "display": { "title": "站回月線(MA20)" },
        "readiness": { "status": "ready", "source_signal_status": "ready", "blocked_reason": null },
        "evidence": { "used_paths": ["primary.break_above_ma20"] }
      }
    ],
    "stories": [],
    "relations": [
      {
        "from": { "type": "index", "code": "break_above_ma20", "name": "站上月線(20MA)", "display": { "title": "站上月線(20MA)" } },
        "to": { "type": "signal", "id": "technical.signal.ta_ma_break_event", "name": "均線穿越事件" },
        "relation": "supports",
        "role": "primary_index",
        "condition": "break_above_ma20 == true",
        "required": true,
        "weight": 0.5
      },
      {
        "from": { "type": "signal", "id": "technical.signal.ta_ma_break_event", "name": "均線穿越事件" },
        "to": { "type": "event", "id": "technical.event.ma_month_reclaim", "name": "站回月線(MA20)" },
        "relation": "triggers",
        "role": "source_signal",
        "condition": "ta_ma_break_event is ready",
        "required": true,
        "weight": 1.0
      }
    ],
    "structured_signals": [
      {
        "signal_code": "ta_ma_break_event",
        "event_code": "ma_month_reclaim",
        "direction": "bullish",
        "confidence": 0.76,
        "source": "signals_events_projection"
      }
    ],
    "signal_summary": {
      "count": 1,
      "source": "signals_events_projection"
    },
    "ta_index_groups": {
      "moving_average_events": {
        "ma20": {
          "distance_pct": -1.2,
          "break_direction": "break_below",
          "break_above": false,
          "break_below": true,
          "days_above": 0,
          "days_below": 1
        }
      },
      "price_acceptance": {
        "price_vs_vwap_pct": -0.8,
        "vwap_position": "below"
      },
      "gap_limit": {
        "gap_pct": -1.5,
        "gap_type": "gap_down",
        "limit_up_hit": false,
        "limit_down_hit": false
      },
      "pattern_signals": {
        "bearish_reversal_pattern_detected": true,
        "top_pattern_name": "DOUBLE_TOP",
        "top_pattern_score": 0.82
      }
    }
  }
]

正式資料流為 indexes -> signals -> events -> stories,並以 relations 表達因果鏈。structured_signals / signal_summary 是 deprecated compatibility alias,內容由 signals / events projection 產生。正式 API response 不提供 canonical 欄位。

Display note: replacement signals[*], events[*], signals[*].index_evidence.<section>.<index_code>, and relations[*].from (when from.type == "index") provide localized Traditional Chinese display.title values for direct frontend rendering.

Name field: every signals[*], events[*], stories[*], and signals[*].index_evidence.<section>.<index_code> entry carries a flat name (Traditional Chinese display name), and every relations[*].from / relations[*].to node carries a name too, so a graph UI can label nodes without joining back to the arrays. Names come from a single registry (config/sesi_names.yaml); name mirrors display.title where both exist. This is an additive, non-breaking field.

Description field: the same signals[*], events[*], stories[*], and index-evidence entries also carry a flat description (Traditional Chinese long-form note) from the same registry. It is authored incrementally, so it is "" for most codes today. This nested SESI description is a distinct field from the legacy top-level SignalResponse.description (the free-text field on the flat signal object) — do not conflate them. description is not added to relations[*] graph nodes (nodes carry name only; look up the entity in the arrays for its description). Additive, non-breaking.


GET /api/signals/stories/latest

首頁用:把當日 ses_story_daily 的 story 命中按 (domain, story_code) 分組,回傳每個 story 及其命中個股清單。唯讀,涵蓋 technical / chip / financial / cross_domain 四域。

Query 參數:

參數 型別 預設 說明
domain technical\|chip\|financial\|cross_domain 全部 限定單一 domain;不給回四域
date YYYY-MM-DD 全域最新 取 requested domains 任一有資料的 MAX(trading_date) ≤ date,再只回該日資料
direction string 全部 依 story direction 篩選(bullish/bearish/risk/support…)

行為說明:

  • 未指定 domain 時,四域共用一個全域最新 Story 日;只回該日 exact-date rows。落後的 domain 不會回退到自己的舊日,因此該 domain 不會出現在 as_of 或 groups。
  • as_of 只列 resolved date 實際有 Story rows 的 domain,所有出現的日期值必定相同。指定單一 domain 時,仍等價於取該 domain 自身的最新日。
  • stocks[] 僅含 ticker + name,按 ticker 排序。不回傳 score(未經前瞻報酬校準的 heuristic,避免被誤讀為機率)與 suppressed_by(本端點不呈現衝突組主/從區別;個股頁端點則會帶出該欄位,見下方 /api/signals/ticker/{ticker} 說明)。
  • 同一檔若當日觸發同組多個 story,會分別出現在各該 story group。
  • 每個 group 的 stocks[] 上限為 signals.stories.max_stocks_per_group(config,預設 500);超限只回前 N 檔並標 truncated: true。
  • conflict_group 為該 story 所屬衝突組;cross_domain story 無衝突組,一律 null。
  • title 為 story 中文名;description 為 story 的繁中敘述(來自 SESI registry sesi_names.yaml,未收錄時為 "")。
  • 錯誤:domain 非四值之一或 date 格式錯 → 422;當日無 story → 200 + groups: []。

Response — StoriesLatestResponse:

{
  "as_of": {
    "technical": "2026-07-06",
    "chip": "2026-07-06",
    "financial": "2026-07-06",
    "cross_domain": "2026-07-06"
  },
  "groups": [
    {
      "domain": "technical",
      "story_code": "new_high_breakout",
      "title": "創新高突破",
      "description": "當股價突破52週新高或波段高點任一時,構成多方創新高突破",
      "direction": "bullish",
      "conflict_group": "technical_breakout_discovery",
      "count": 12,
      "truncated": false,
      "stocks": [
        {"ticker": "2330.TW", "name": "台積電"},
        {"ticker": "2454.TW", "name": "聯發科"}
      ]
    }
  ]
}

GET /api/signals/market-regime-today

取得今日大盤環境研判。

Response — MarketRegimeTodayResponse:

{
  "status": "ok",
  "trading_date": "2024-03-15",
  "ticker": "^TWII",
  "close": 20150.5,
  "confirmed_regime": "bull_trend",
  "stress_confirmed": false,
  "regime_label": "多頭趨勢",
  "ma60": 19800.0,
  "ma120": 19200.0,
  "ma200": 18500.0,
  "ma120_slope_pct": 0.15,
  "atr_ratio": 0.012,
  "drawdown_pct": -2.5,
  "stress_by_atr": false,
  "stress_by_drawdown": false,
  "stress_by_return_3d": false,
  "ma_structure": "bullish_aligned",
  "interpretation": "大盤處於多頭格局...",
  "current_segment": {
    "regime": "bull_trend",
    "start_date": "2024-01-15",
    "end_date": "2024-03-15",
    "duration_days": 60,
    "total_return_pct": 8.5,
    "max_drawdown_pct": -3.2,
    "stress_day_ratio": 0.05
  },
  "acute_shock_alert": null,
  "acute_shock_score": null,
  "acute_shock_reasons": null,
  "risk_mode": null
}

status 可能值: "ok" | "no_data" | "warmup"


GET /api/signals/market-regime-range

取得大盤趨勢區間資料(每日 regime + 跨區段 segments)。

Query Parameters:

參數 類型 預設 說明
start_date string (YYYY-MM-DD) config default_lookback_days 天前 起始日
end_date string (YYYY-MM-DD) today 結束日

驗證規則: - start_date 必須 ≤ end_date,否則 422 - 區間不得超過 config max_range_days(預設 365 天),否則 422

Response — MarketRegimeRangeResponse:

{
  "ticker": "^TWII",
  "start_date": "2025-01-01",
  "end_date": "2025-03-20",
  "count": 55,
  "daily": [
    {
      "trading_date": "2025-01-02",
      "close": 18500.0,
      "confirmed_regime": "bull",
      "stress_confirmed": false,
      "regime_label": "bull_normal",
      "ma120_slope_pct": 0.5,
      "atr_ratio": 1.1,
      "drawdown_pct": -2.0,
      "acute_shock_alert": "none",
      "acute_shock_score": 0,
      "risk_mode": "risk_on"
    }
  ],
  "segments": [
    {
      "regime": "bull",
      "start_date": "2024-12-01",
      "end_date": "2025-03-20",
      "duration_days": 80,
      "total_return_pct": 5.0,
      "max_drawdown_pct": -3.0,
      "stress_day_ratio": 0.1
    }
  ]
}

GET /api/signals/market-pulse

市場脈動總覽。

Response — MarketPulseResponse:

{
  "signal_date": "2024-03-15",
  "total_tickers": 500,
  "bullish_count": 280,
  "bearish_count": 150,
  "neutral_count": 70,
  "avg_confidence": 0.62,
  "high_confidence_bullish": 85,
  "high_confidence_bearish": 45,
  "overbought_count": 30,
  "oversold_count": 25,
  "aggressive_buying_count": 40,
  "aggressive_selling_count": 20,
  "golden_cross_count": 15,
  "death_cross_count": 8,
  "kdj_golden_cross_count": 21,
  "kdj_death_cross_count": 12,
  "ma_month_reclaim_count": 42,
  "ma_month_breakdown_count": 31,
  "ma_quarter_reclaim_count": 18,
  "ma_quarter_breakdown_count": 22,
  "ma_half_year_reclaim_count": 7,
  "ma_half_year_breakdown_count": 10,
  "ma_year_reclaim_count": 4,
  "ma_year_breakdown_count": 6,
  "vwap_above_count": 260,
  "vwap_below_count": 240,
  "gap_up_count": 12,
  "gap_down_count": 9,
  "limit_up_count": 3,
  "limit_down_count": 1,
  "channel_breakout_count": 20,
  "channel_breakdown_count": 16,
  "bullish_reversal_pattern_count": 11,
  "bearish_reversal_pattern_count": 8
}

GET /api/signals/market-pulse/tickers

/market-pulse 統計計數的個股名單下鑽(Cortex#222)——給定一個 belt 計數 metric,回傳該日符合條件的個股清單。

Query Params:

名稱 類型 必填 預設 說明
metric string 是 — MarketPulseResponse 26 個 belt 計數 key 之一(如 ma_month_reclaim_count、overbought_count);未知值回 422
limit int 否 50 1-200

目標交易日與 base WHERE 與 /market-pulse 完全一致(MAX(trading_date) probe、interval='1d'、data_quality='complete'、ticker LIKE '%.TW' OR '%.TWO');metric→SQL 條件逐字取自 /market-pulse 的 FILTER 子句。

Response — MarketPulseTickersResponse:

{
  "signal_date": "2024-03-15",
  "metric": "ma_month_breakdown_count",
  "total": 240,
  "items": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "close": 1080.0,
      "change_percent": -6.5,
      "rsi_value": 28.0,
      "volume_ratio": 2.1,
      "gap_pct": 5.0
    }
  ]
}

total 為該 metric 條件下的全數個股數(同 /market-pulse 對應 FILTER count);items 依 limit 截斷,排序 ABS(change_percent) DESC NULLS LAST(純呈現排序,非篩選條件)。

跳空定義(2026-07-29 修正):gap_up_count/gap_down_count 採教科書跳空缺口——開盤越過昨日最高/最低(open_above_prev_high/open_below_prev_low),非「開盤 vs 昨收 ±1%」的開高開低(該寬鬆版仍存於 snapshot 的 gap_type 欄位,statistics 不再使用)。gap_pct 為開盤相對昨日收盤的幅度,供名單對照;change_percent 是收盤結果,跳空開出後收盤仍可反向(開高走低)。


GET /api/signals/ticker/{ticker}

個股訊號歷史。

Query Params:

名稱 類型 預設 說明
days int 30 天數(1-365)

Response: SignalResponse[](同 /latest schema)


12. 每日分析 Daily Analysis

Prefix: /api/daily-analysis

GET /api/daily-analysis/{ticker}/latest

取得最新每日分析。

meta 識別欄位(issue #571):新增 meta.trading_date(ISO 日期,標識快照所屬交易日);移除 meta.schema_version / meta.timestamp / meta.exchange_tz(原為每列常數,時區定義在 config/markets.yaml)。

回傳 build_agent_payload 的完整技術 payload。TA payload 新增: - ta_index_groups: MA line break、EMA distance、VWAP、normalized volatility、channel position、gap/limit、extra oscillators、pattern signals。 - signals / events / stories / relations: 正式 replacement arrays。 - indexes / context / risk / quality: index、情境、風險與資料品質區塊。 - structured_signals / signal_summary: deprecated compatibility alias,來源為 signals / events projection。

Storage note: relations 是 API read-time derived field,不是 DB historical truth。DB snapshot rows persist index/source fields and may keep compact signals_json / events_json / stories_json cache; dense index -> signal relation graphs must not be full-history materialized.


GET /api/daily-analysis/{ticker}

取得日期範圍內的每日分析。

Query Params:

名稱 類型 必填 說明
from string 是 起始日期 YYYY-MM-DD
to string 是 結束日期 YYYY-MM-DD

舊的三層 sidecar include flag 已移除;若舊 client 仍送此 query param,API 會忽略它。Range item 一律回傳 replacement signals / events / stories / relations。

Range responses derive replacement arrays from persisted index/source snapshot fields when compact cache is absent. Clients should treat relations as trace output, not as separately persisted domain data.


POST /api/daily-analysis/run

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

手動觸發每日分析。

Request Body:

{
  "date": "2024-03-15",       // optional
  "ticker": "2330.TW",        // optional, 空=全市場
  "market": "taiwan",         // default "taiwan"
  "backfill_days": 5          // optional, 補回填天數
}

Response: { "status": "ok", "stats": { ... } },stats 為該次執行的統計:

欄位 類型 說明
target_date string 實際執行的交易日(超過最新 OHLC 日期會被 clamp)
market string 市場
ticker string | null 單檔模式的 ticker,全市場為 null
backfill_days int | null 回填天數
symbols_total int 本次實際處理的標的數(全市場模式下 = eligible − window_no_bar,分片模式下為該分片的份額)
snapshots_total int 本次預計寫入的 (標的 × 日期) 筆數
success int 寫入成功筆數
skipped int 略過筆數
failed int 失敗筆數
eligible int 目標日官方名冊判定「當日上市且有報價史」的代碼數(issue #1537)
data_missing int 當日上市但到該日為止完全沒有報價的代碼數
expected_skipped int 依官方名冊判定「本就不該計算」的代碼數(未上市/已下市/從未在官方名冊)
expected_skip_reasons object 上述原因分佈,如 {"delisted": 3}
window_no_bar int 名冊內但本次時間窗內無 K 棒的代碼數(與 data_missing 分開計數)
backfill_dates_not_listed int 多日回補模式下,因當日官方名冊未判定該代碼「當日上市」而被剔除的 (標的 × 日期) 筆數(issue #1537 §2.4)。單檔模式與單日模式恆為 0
anchor_fallback_date string | null 恆存在,且經本端點呼叫恆為 null(本端點無法進入該退化狀態,理由見下方說明)。非 null 只可能出現在 scheduler 除權息調整重播與 CLI --ticker + --include-inactive 兩條路徑,值為那次退回所用的錨點日期(ISO YYYY-MM-DD),代表該次寫入的快照是以那個舊日期算的(issue #1537,使用者裁決 2026-09-03)

名冊改由目標日官方上市證據決定(不再是今日 taiwan.stocks.is_active)。該日 官方名冊缺失或自相矛盾時,此端點回 500(不會回傳以未知名冊算出的空名冊結果)。

上表的 symbols_total = eligible − window_no_bar 僅在全市場單日模式成立。帶 ticker 的單檔模式不解析名冊,eligible / data_missing / expected_skipped / window_no_bar 四個計數器恆為 0,而 symbols_total 為 1。單檔模式的 target_date 以全市場最新 OHLC 日期為錨(省略時取全市場最新交易日;超過該日才 clamp),不以 該檔自己的最後報價日為錨——請求什麼區間就算什麼區間(issue #1537)。單檔模式雖然不 解析名冊,下述回補日期的日曆窗口下界一樣適用:帶 backfill_days 的單檔請求只計算 [target_date − backfill_days 日曆天, target_date] 內有 K 棒的交易日,窗口之前的 日期不再回補。若該標的在窗口內完全沒有 K 棒(例如最後報價早於窗口),端點仍回 200 且 symbols_total 為 1,但 snapshots_total / success 皆為 0(單檔模式不計 window_no_bar,原因只以 WARNING 記在 log)。

不帶 backfill_days 的單檔單日請求是另一種形狀:回補日期恆為 [target_date] 本身(不先查該標的有沒有 K 棒),因此當該標的在 target_date 當日 沒有可用 K 棒時——例如當日官方名冊列為 no_published_price(有掛牌但未公布價格, issue #1557),或歷史深度不足以算指標——端點仍回 200,回傳 {"symbols_total": 1, "snapshots_total": 1, "success": 0, "skipped": 1}: snapshots_total 為 1(該日確實被請求了),success 為 0,差額落在 skipped。 此情形以 WARNING 記在 log(載明標的與日期);全市場模式不逐檔記錄,因為正常交易日 本就會依設計跳過數十檔。

anchor_fallback_date 所描述的退化狀態(taiwan.stocks 內沒有任何 active 代碼持有 K 棒,單檔執行改以該檔自己的最後報價日為錨)經本端點呼叫不可能發生,此欄在本端點回傳的 stats 中恆為 null:DailyAnalysisRunRequest 沒有 include_inactive 欄位、handler 也 不傳,本端點一律以 include_inactive=False 執行;此模式下單檔錨點查詢與全市場錨點查詢用 同一個 JOIN taiwan.stocks ... WHERE s.is_active = true,前者只多一條 AND o.stock_code = :stock_code,所以全市場錨點為 NULL 時單檔錨點必為 NULL,執行會先以 「No stock_ohlc trading date available」中止(回 500),走不到退回分支。此欄仍列在上表, 因為它是本端點回傳 dict 的固定欄位。

能讓此欄非 null 的是另外兩條非 HTTP 路徑:scheduler 的除權息調整重播 (_replay_adjustment_ticker_history,以 include_inactive=True 帶單檔執行,且一旦此欄 非 null 就中止並要求人工對帳),以及 CLI 同時帶 --ticker 與 --include-inactive (--include-inactive 的說明本就標示僅供全市場模式,argparse 不擋這個組合)。維運處置 (已 commit 的舊窗口如何推算、重算、補回)見 docs/runbooks/db-restore.md D 節。

帶 backfill_days 的全市場多日回補另有兩點差異(issue #1537 §2.4):名冊改取窗口內各交易日 eligible 的聯集,好讓區間內曾上市、終端日已下市 的代碼仍能重建它有效期間的快照,因此 symbols_total 可能大於 eligible − window_no_bar(eligible 等四個計數器仍只描述 target_date 當日,不跨日累加); 每個標的的回補日期再逐日以該日名冊過濾,被剔除的計入 backfill_dates_not_listed, snapshots_total 以過濾後的日期數計算。逐日過濾與單日模式同樣 fail-closed:任一 被請求的日期查無官方上市證據即整個執行中止並回 500,不會以部分結果回 200。

回補日期的範圍與名冊窗口同界:backfill_days 取的是該標的在 [target_date − backfill_days 日曆天, target_date] 內、最多 backfill_days + 1 個有 K 棒的交易日;窗口之前的日期一律不計算(使用者裁決 2026-09-03「技術分析回補 就應該只回補範圍內技術分析可算的內容」)。因此該區間內完全沒有 K 棒的代碼 (例如已下市、最後報價早於窗口)不會產生任何快照,並計入 window_no_bar。


12B. 個股分析與管理端 Analysis & Admin

POST /api/analyze

執行單檔個股分析(技術面趨勢、支撐/壓力、VRR/LLM 敘事)。

🔒 Auth: 需登入(Authorization: Bearer <access_token>)。無效 token 回 401;未帶 Authorization header 回 422。

Request Body: { "ticker": "2330.TW", ... }


POST /api/admin/update-data

觸發背景資料更新工作(依市場更新股價/籌碼等)。

🔒 Auth: 需 admin 權限。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

Request Body: { "markets": ["taiwan"], "force": false } (markets 選填,省略=全部市場)


GET /api/admin/jobs

列出所有背景工作狀態。

🔒 Auth: 需 admin 權限。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。


GET /api/admin/jobs/{job_id}

查詢單一背景工作狀態。

🔒 Auth: 需 admin 權限。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。


13. 訊號回測 Signal Backtest

Prefix: /api/signals/backtest

POST /api/signals/backtest/run

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

執行訊號回測。

Request Body:

{
  "domain": "technical",        // "technical" | "chip" | "financial"
  "test_days": 180,             // optional, 回測天數
  "start_date": "2024-01-01",   // optional
  "end_date": "2024-06-30",     // optional
  "tickers": ["2330.TW"],       // optional, 空=全部
  "cooldown_days": 3            // optional, 冷卻天數(0=不冷卻)
}

GET /api/signals/backtest/export/csv

匯出回測結果為 CSV。


GET /api/signals/backtest/accuracy/signal-type

依訊號類型的回測準確度。


GET /api/signals/backtest/accuracy/ticker/{ticker}

個股回測準確度。


POST /api/signals/backtest/accuracy/tickers

批次查詢多檔個股準確度。

Request Body:

{
  "tickers": ["2330.TW", "2317.TW"],
  "run_id": 1,              // optional, 預設最新一次
  "domain": "technical"      // "technical" | "chip"
}

GET /api/signals/backtest/accuracy

整體回測準確度矩陣。


14. MAS 多代理分析

Prefix: /api

POST /api/v1/mas/analyze ⭐ 主要使用

🔒 Paid: 需登入且 DB 即時會員等級為 advance 或 premium。JWT tier claim 不作授權依據;free、null、unknown 或 inactive tier 回 403 TIER_REQUIRED。admin 帳號(DB is_admin=true)不受 tier 限制。無效 token 回 401;未帶 Authorization header 回 422。

V1 MAS 分析(含快取)。

Request Body:

{
  "ticker": "2330.TW",
  "trading_date": "2024-03-15",    // optional, 預設最新
  "response_mode": "stream",       // "stream" (SSE) | "json"
  "target": "orchestrator",        // "orchestrator" | "technical" | "chip" | "financial"
  "options": {}                    // optional
}

response_mode: "stream" — 回傳 Server-Sent Events (SSE):

Content-Type: text/event-stream

data: {"type": "agent_start", "agent": "technical", ...}
data: {"type": "agent_result", "agent": "technical", "content": "..."}
data: {"type": "agent_start", "agent": "chip", ...}
data: {"type": "agent_result", "agent": "chip", "content": "..."}
data: {"type": "final", "content": "..."}

response_mode: "json" — 回傳完整 JSON。

多空對照欄位(target: "orchestrator"): 綜合結果(orchestrator_output / SSE done 事件的 result)除既有 key_insights / risks / recommendations 外,另含 bull_points 與 bear_points(各 ≤3 條,{text, evidence} 結構,evidence 帶 [技術面]/[籌碼面]/[財務面] 來源前綴)——三個 agent 已提供事實依多空方向分桶的彙整視圖。舊快取列無此欄位時反序列化為空陣列。

SSE 串流實際以 message 事件逐行送出各區塊,meta.section 標記段落;多空對照對應新增的 section 值 bull_point / bear_point,例如:

data: {"v": 1, "seq": 42, "request_id": "…", "ticker": "3231.TW", "trading_date": "2026-07-28", "ts": "…", "agent": "orchestrator", "type": "message", "message": "🟢 法人資金動能延續,外資與投信呈現連續買超態勢。  📎 [籌碼面] foreign_streak_days=7", "meta": {"section": "bull_point"}}
data: {"v": 1, "seq": 43, "request_id": "…", "ticker": "3231.TW", "trading_date": "2026-07-28", "ts": "…", "agent": "orchestrator", "type": "message", "message": "🔴 短線動能指標出現死亡交叉,波動率處於偏高水平。  📎 [技術面] kdj_cross_signal=death_cross", "meta": {"section": "bear_point"}}

(本節上方的 agent_start/agent_result/final 示意為簡化描述,與實際 event schema 的整體對齊列於 follow-up。)

target 說明:

target 說明
orchestrator 完整分析(技術+籌碼+財務→綜合判斷)
technical 僅技術面 agent
chip 僅籌碼面 agent
financial 僅財務面 agent

POST /api/mas/analyze (Legacy)

🔒 Paid: 需登入且 DB 即時會員等級為 advance 或 premium。JWT tier claim 不作授權依據;free、null、unknown 或 inactive tier 回 403 TIER_REQUIRED。admin 帳號(DB is_admin=true)不受 tier 限制。無效 token 回 401;未帶 Authorization header 回 422。

舊版 MAS 分析(需自行提供 agent JSON)。

{
  "ticker": "2330.TW",
  "technical_json": { ... },
  "chip_json": { ... },
  "financial_json": { ... }
}

POST /api/mas/agent/{domain} (Legacy)

🔒 Paid: 需登入且 DB 即時會員等級為 advance 或 premium。JWT tier claim 不作授權依據;free、null、unknown 或 inactive tier 回 403 TIER_REQUIRED。admin 帳號(DB is_admin=true)不受 tier 限制。無效 token 回 401;未帶 Authorization header 回 422。

舊版單一 agent 分析。

{
  "ticker": "2330.TW",
  "domain_json": { ... }
}

14.5 Stock Signals (Consolidated)

GET /api/signals/stock/{ticker}

三面向(技術/籌碼/財務)個股訊號整合端點。固定使用 interval='1d' 的 daily snapshots。

Storage note: this endpoint returns formal signals / events / stories / relations, but frontends must not assume relations_json is stored in DB. Relations are deterministic trace projections from signal/event/story definitions and current domain objects.

Frontend note: relations includes trace edges such as index -> signal, signal -> event, signal -> story, and event -> story. Factor joins should use from.type == "index" / to.type == "signal" relations, not DB relations_json. Replacement signals[*], events[*], stories[*], index evidence entries, and index relation nodes include display.title when a display label can be derived. Additionally, every signals[*], events[*], stories[*], and index-evidence entry carries a flat name (Traditional Chinese), and every relations[*] node (from/to, all types) carries a name, so a graph UI can label nodes without joining back to the arrays. Names come from a single registry (config/sesi_names.yaml, the authoritative source); name mirrors display.title where both exist. Additive, non-breaking. Those same entity entries (but not relation nodes) also carry a flat description (Traditional Chinese, from the same registry, "" until authored) — distinct from the legacy top-level SignalResponse.description.

Cross-domain note: the response also includes a cross_domain DomainPayload (same shape as the three per-domain fields) synthesizing the three domains into cross-domain stories (e.g. high_conviction_long). Its relations are cross-linkage only — signal -> event, signal -> story, event -> story — and deliberately omit index -> signal edges (those are already provided per-domain, so they are not repeated here). cross_domain is null when any of the three domains has no data. When the three domains resolve to different trading dates (e.g. chip lagging over a weekend), cross_domain.stories is empty and cross_domain.quality.diagnostic carries the reason ({state, reason: "cross_domain_source_mismatch", ...}) — this is expected, not an error. Note cross_domain.quality therefore has a different shape from the per-domain quality ({data_quality, warnings}). cross_domain only populates signals/events/stories/relations (+ quality on misalignment); its indexes/context/risk/ses_cache_status are always empty/null (those are per-domain-only concepts).

Chip cost-zone note (v1.5.0): chip.indexes.cost_zones is the V3 institutional cost-zone object sourced from chip.context.chip_structure. Cost windows default to 60 trading days and can be changed with cost_window_days on /api/agent/chip/{ticker}. Each actor zone (foreign/trust/dealer/composite) keeps the legacy net-view band / position, and additionally exposes buy_cost_zone, sell_cost_zone, and net_cost_zone. The net view uses signed Net Cash Flow VWAP: avg_cost = Σ(close × net_capped) / Σ(net_capped) where net_capped is the symmetric ±median(|net|)×5 clip. Directional buy/sell views use only buy-flow or absolute sell-flow weights, so net_cost_zone.status=distorted can still coexist with available buy/sell costs. Each band exposes avg_cost, median_cost, and poc_price; each meta carries diagnostics such as window_days, net_cumulative_shares, total_abs_flow, and churn_ratio. v1.6.0: each buy_cost_zone / sell_cost_zone additionally carries histogram — either null (no data: no flow in that direction, or an all-flat price window) or { "prices": number[20], "shares": int[20] }, a volume-profile-style distribution of the clipped directional flow. prices are bin midpoints on one shared price grid (min→max of the window's closes split into 20 equal bins), so histograms from different actors/directions are bin-aligned and the composite histogram is the element-wise sum of its component actors' histograms; sum(shares) == meta.total_shares. The histogram key is always present in newly computed payloads (stored evidence lacking the key predates this field and is recomputed at request time). The old V2 chip_price_zones / margin_crowded_zone payload is no longer used for this field.

Chip support/resistance note:chip.indexes 新增兩個欄位,既有 support_resistance 不變(籌碼分析未就緒時本端點整個 chip 為 null;欄位說明見 §15 GET /api/agent/chip/{ticker}): - high_volume_support_resistance:價量支撐壓力,原樣回傳 chip evidence 頂層同名區塊;本功能上線前寫入的快照為 {}。 - institutional_support_resistance:法人支撐壓力,與 support_resistance 為同一物件(別名)。

Support dual-basis note (issue #1253, migration 125): technical.context 在既有 support / resistance(還原基準)旁額外提供 support_raw / resistance_raw / structure_stop_raw(名目基準,來源為 major_support_raw / major_resistance_raw / structure_stop_raw)。既有欄位語意與值不變;兩種基準的差異與適用場景見 §11 支撐族雙基準。

放量破底(#1721):技術訊號的 supporting evidence 新增 story_swing_low_price / story_swing_low_date,表示完整還原歷史中最近已確認的前低 (左右各 5 根確認,沒有 20/60 日或其他回看上限)。swing_low_breakdown 事件要求收盤嚴格低於該前低;high_volume_breakdown 再要求放量賣壓。 缺少已確認前低時不產生該事件。既有 context support、resistance、stop 欄位語意不變。

Query Parameters:

參數 類型 預設 說明
qryDate string latest YYYY-MM-DD,嚴格同日查詢,不 fallback;未帶時籌碼維度使用該股法人或融資券不晚於官方交易日的最新來源日

Response: StockSignalsResponse

{
  "ticker": "2330.TW",
  "trading_date": "2026-03-28",
  "technical": {
    "signals": [{ "id": "technical.signal.ta_ma_stack_position", "signal_code": "ta_ma_stack_position", "name": "均線排列位階", "display": { "title": "均線排列位階" } }],
    "events": [{ "id": "technical.event.ma_bullish_stack", "event_code": "ma_bullish_stack", "signal_code": "ta_ma_stack_position", "name": "均線多頭排列", "display": { "title": "均線多頭排列" } }],
    "stories": [],
    "relations": [
      {
        "from": { "type": "index", "code": "ma_alignment", "name": "均線排列", "display": { "title": "均線排列" } },
        "to": { "type": "signal", "id": "technical.signal.ta_ma_stack_position", "name": "均線排列位階" },
        "relation": "supports",
        "role": "primary_index",
        "condition": "ma_alignment present",
        "required": true,
        "weight": 0.5
      },
      {
        "from": { "type": "signal", "id": "technical.signal.ta_ma_stack_position", "name": "均線排列位階" },
        "to": { "type": "event", "id": "technical.event.ma_bullish_stack", "name": "均線多頭排列" },
        "relation": "triggers",
        "role": "source_signal",
        "condition": "ta_ma_stack_position is ready",
        "required": true,
        "weight": 1.0
      }
    ],
    "indexes": {
      "ta_index_groups": { "moving_average_events": { "...": "..." } },
      "technical_indicators": {
        "trend": { "ma_alignment": "bullish_stack", "cti_score": 0.72 },
        "momentum": { "macd_histogram": 2.5 },
        "volatility": { "bb_status": "normal" },
        "volume": {
          "volume_ratio": 1.35,
          "anomaly": {
            "price_return_1d": 0.031,
            "price_z_20d": 2.3,
            "volume_z_20d": 2.5,
            "baseline_count": 20,
            "baseline_span_days": 27,
            "state": "surge_up"
          }
        }
      },
      "indicator_settings": {
        "moving_averages": { "type": "SMA", "periods": [5, 20, 60, 120, 240], "source": "close" }
      }
    },
    "context": {
      "market_summary": { "bias": "bullish", "confidence": 0.75, "ma_alignment": "bullish_stack" },
      "patterns": [
        {
          "name": "DOUBLE_BOTTOM",
          "score": 0.70,
          "reliability": "medium",
          "trigger_price": 110.0,
          "invalidation_price": 95.0,
          "stage": "watch"
        }
      ],
      "support": 875.0,
      "resistance": 925.0,
      "support_raw": 858.0,
      "resistance_raw": 908.0,
      "structure_stop_raw": 858.0
    },
    "risk": {},
    "quality": { "data_quality": "complete", "warnings": [] }
  },
  "chip": {
    "signals": [{ "id": "chip.signal.chip_institutional_flow_intensity", "signal_code": "chip_institutional_flow_intensity", "name": "三大法人流向強度", "display": { "title": "三大法人流向強度" } }],
    "events": [{ "id": "chip.event.flow_volume_aligned_buy", "event_code": "flow_volume_aligned_buy", "signal_code": "chip_institutional_flow_intensity", "name": "量價買盤一致", "display": { "title": "量價買盤一致" } }],
    "stories": [{ "id": "chip.story.accumulation", "story_code": "accumulation", "source_signal_codes": ["chip_inst_flow"], "source_event_codes": ["flow_volume_aligned_buy"], "name": "法人吸籌", "display": { "title": "法人吸籌" } }],
    "relations": [
      {
        "from": { "type": "event", "id": "chip.event.flow_volume_aligned_buy", "name": "量價買盤一致" },
        "to": { "type": "story", "id": "chip.story.accumulation", "name": "法人吸籌" },
        "relation": "supports",
        "role": "source_event",
        "condition": "flow_volume_aligned_buy active",
        "required": true,
        "weight": 1.0
      }
    ],
    "indexes": {
      "raw_chip_summary": { "foreign_flow_zscore": 1.85, "inst_consensus": "bullish" },
      "institutional_flow": { "foreign_net_shares": 1000, "foreign_flow_zscore": 1.85, "inst_consensus": "bullish" },
      "margin": { "margin_balance": 12000, "margin_change_pct": 1.69 },
      "short": { "short_balance": 800, "short_change_pct": -5.88 },
      "ownership": {},
      "cost_zones": {
        "foreign_cost_zone": {
          "exists": true,
          "band": {
            "lower": 2250.94,
            "upper": 2319.49,
            "avg_cost": 2285.21,
            "median_cost": 2282.50,
            "poc_price": 2280.00
          },
          "position": "above",
          "distance_pct": 4.16,
          "penetration_pct": null,
          "confidence": 0.82,
          "flags": [],
          "meta": {
            "flow_sign": "net",
            "positive_flow_days": 8,
            "effective_window_days": 60,
            "net_cumulative_shares": 1840000,
            "total_abs_flow": 3210000,
            "churn_ratio": 0.5732,
            "extreme_day_capped": false,
            "concentration_ratio": 0.18
          }
        },
        "trust_cost_zone": { "exists": true, "band": { "avg_cost": 2239.90, "median_cost": 2240.10, "poc_price": 2238.00 }, "position": "above" },
        "dealer_cost_zone": { "exists": true, "band": { "avg_cost": 2310.98, "median_cost": 2309.75, "poc_price": 2308.50 }, "position": "above" },
        "composite_cost_zone": {
          "exists": true,
          "band": {
            "lower": 2241.56,
            "upper": 2309.83,
            "avg_cost": 2275.70,
            "median_cost": 2273.40,
            "poc_price": 2272.00
          },
          "position": "above",
          "components": ["foreign", "trust", "dealer"],
          "weights": { "foreign": 0.6, "trust": 0.25, "dealer": 0.15 }
        },
        "chip_support_resistance": {
          "active_support": null,
          "secondary_support_candidates": [],
          "primary_sell_pressure_zone": null,
          "primary_buy_pressure_zone": null
        }
      },
      "support_resistance": {
        "active_support": null,
        "secondary_support_candidates": []
      },
      "institutional_support_resistance": { "...": "same object as chip.indexes.support_resistance" },
      "high_volume_support_resistance": { "...": "stored chip evidence block high_volume_support_resistance; {} for snapshots written before the feature" }
    },
    "context": {
      "chip_structure": { "...": "same V3 object as chip.indexes.cost_zones" },
      "chip_leverage": {},
      "chip_state": {},
      "triggered_rules": []
    },
    "risk": { "veto_triggered": false, "veto_reason": null },
    "quality": { "data_quality": "complete", "warnings": [] }
  },
  "financial": {
    "signals": [{ "id": "financial.signal.fin_revenue_growth_momentum", "signal_code": "fin_revenue_growth_momentum", "name": "營收成長動能", "display": { "title": "營收成長動能" } }],
    "events": [{ "id": "financial.event.revenue_acceleration", "event_code": "revenue_acceleration", "signal_code": "fin_revenue_growth_momentum", "name": "營收成長加速", "display": { "title": "營收成長加速" } }],
    "stories": [{ "id": "financial.story.earnings_acceleration", "story_code": "earnings_acceleration", "source_signal_codes": ["fin_growth_quality"], "source_event_codes": ["revenue_acceleration"], "name": "獲利加速", "display": { "title": "獲利加速" } }],
    "relations": [
      {
        "from": { "type": "event", "id": "financial.event.revenue_acceleration", "name": "營收成長加速" },
        "to": { "type": "story", "id": "financial.story.earnings_acceleration", "name": "獲利加速" },
        "relation": "supports",
        "role": "source_event",
        "condition": "revenue_acceleration active",
        "required": true,
        "weight": 1.0
      }
    ],
    "indexes": {
      "growth_score": 0.65,
      "valuation_score": 0.55,
      "valuation_regime": "fair",
      "revenue": { "revenue_yoy_pct": 18.5, "revenue_mom_pct": 4.2 },
      "profitability": { "eps_latest": 8.2, "roe_annualized": 28.0 },
      "valuation": { "pe_ratio": 18.2, "pb_ratio": 4.8, "valuation_regime": "fair" },
      "industry": {}
    },
    "context": {
      "financial_matrix": { "position": "bullish", "final_score": 0.62 },
      "growth_dimension": { "score": 0.65, "signal_type": "growth_moderate" },
      "valuation_dimension": { "score": 0.55, "signal_type": "fair" },
      "industry_comparison": {},
      "price_context": { "closing_price": 900.0 }
    },
    "risk": { "veto_triggered": false, "veto_reason": null },
    "quality": { "data_quality": { "status": "complete" }, "warnings": [] }
  },
  "technical_signals": {
    "trading_date": "2026-03-28",
    "signal_type": "ta_ma_stack_position",
    "bias": "bullish",
    "confidence": 0.75,
    "patterns": [
      {
        "name": "DOUBLE_BOTTOM",
        "score": 0.70,
        "reliability": "medium",
        "trigger_price": 110.0,
        "invalidation_price": 95.0,
        "stage": "watch"
      }
    ],
    "support": 875.0,
    "resistance": 925.0,
    "support_raw": 858.0,
    "resistance_raw": 908.0,
    "structure_stop_raw": 858.0,
    "structured_signals": [
      {
        "signal_code": "ta_ma_stack_position",
        "event_code": "ma_bullish_stack",
        "direction": "bullish",
        "confidence": 0.75,
        "source": "signals_events_projection"
      }
    ],
    "signal_summary": {
      "count": 1,
      "source": "signals_events_projection"
    },
    "ta_index_groups": { "moving_average_events": { "...": "..." } },
    "signals": [{ "id": "technical.signal.ta_ma_stack_position", "signal_code": "ta_ma_stack_position" }],
    "events": [{ "id": "technical.event.ma_bullish_stack", "event_code": "ma_bullish_stack", "signal_code": "ta_ma_stack_position" }],
    "stories": [],
    "relations": [
      {
        "from": { "type": "signal", "id": "technical.signal.ta_ma_stack_position" },
        "to": { "type": "event", "id": "technical.event.ma_bullish_stack" },
        "relation": "triggers",
        "role": "source_signal",
        "condition": "ta_ma_stack_position is ready",
        "required": true,
        "weight": 1.0
      }
    ],
    "reasons": ["MA_BULLISH_STACK"],
    "warnings": []
  },
  "chip_signals": {
    "trading_date": "2026-03-28",
    "chip_signal_type": "chip_accumulation",
    "bias": "bullish",
    "confidence": 0.72,
    "story_type": "accumulation",
    "role": "leading",
    "story_reasons": ["foreign_sustained_buy"],
    "action_hints": ["hold_or_add"],
    "foreign_flow_zscore": 1.85,
    "inst_consensus": "bullish",
    "signals": [{ "id": "chip.signal.chip_inst_flow", "signal_code": "chip_inst_flow" }],
    "events": [{ "id": "chip.event.flow_volume_aligned_buy", "event_code": "flow_volume_aligned_buy", "signal_code": "chip_inst_flow" }],
    "stories": [{ "id": "chip.story.accumulation", "story_code": "accumulation", "source_signal_codes": ["chip_inst_flow"], "source_event_codes": ["flow_volume_aligned_buy"] }],
    "relations": [
      {
        "from": { "type": "event", "id": "chip.event.flow_volume_aligned_buy" },
        "to": { "type": "story", "id": "chip.story.accumulation" },
        "relation": "supports",
        "role": "source_event",
        "condition": "flow_volume_aligned_buy active",
        "required": true,
        "weight": 1.0
      }
    ],
    "reasons": ["FOREIGN_NET_BUY"],
    "warnings": []
  },
  "financial_signals": {
    "trading_date": "2026-03-28",
    "bias": "bullish",
    "confidence": 0.68,
    "growth_signal": "growth_moderate",
    "valuation_regime": "fair",
    "matrix_position": "bullish",
    "story_type": "earnings_acceleration",
    "role": "supporting",
    "story_reasons": ["revenue_acceleration"],
    "action_hints": ["growth_intact"],
    "signals": [{ "id": "financial.signal.fin_growth_quality", "signal_code": "fin_growth_quality" }],
    "events": [{ "id": "financial.event.revenue_acceleration", "event_code": "revenue_acceleration", "signal_code": "fin_growth_quality" }],
    "stories": [{ "id": "financial.story.earnings_acceleration", "story_code": "earnings_acceleration", "source_signal_codes": ["fin_growth_quality"], "source_event_codes": ["revenue_acceleration"] }],
    "relations": [
      {
        "from": { "type": "event", "id": "financial.event.revenue_acceleration" },
        "to": { "type": "story", "id": "financial.story.earnings_acceleration" },
        "relation": "supports",
        "role": "source_event",
        "condition": "revenue_acceleration active",
        "required": true,
        "weight": 1.0
      }
    ],
    "reasons": ["REVENUE_YOY_STRONG"],
    "warnings": []
  }
}

Example note: some signal_code / story_code values above (e.g. accumulation, chip_inst_flow, fin_growth_quality) are illustrative placeholders and are not guaranteed to resolve via the config/sesi_names.yaml registry; the name shown for them is representative only. In real responses each code is a live SESI code whose name comes from the registry.

qryDate 語義: - 未帶:各面向查各自最新 trading_date(可能不同日);籌碼面為該股最新來源日,該日分析未完成時 chip 為 null,不回退前一日 - 有帶:嚴格同日查詢,不 fallback 到其他日期 - 三面向全無資料:404 - 指定日某面向無資料:該面向回 null - 每個面向物件都使用 replacement signals / events / stories / relations。Deprecated aliases(如 signal_type、story_type、structured_signals)只由 replacement arrays projection 產生。 - technical / chip / financial 是正式 domain payload。technical_signals / chip_signals / financial_signals 只保留為 deprecated compatibility aliases,內容必須由正式 payload projection 產生。 - stories[] 回該面向全部 story,含衝突組的敗方。 每個 domain 的 stories[] 依引擎排序(priority 優先,同 priority 再比 score)全部回傳,不做每域 top-N 修剪(#2114:前端預設顯示前 3 個、展開看完整),也不依 suppressed_by 剔除。同一衝突組的主/從 story 因此可能同時出現。列表端點(GET /api/signals/latest,含 ?ticker=&view=full;GET /api/signals/chip-highlights、GET /api/signals/financial-highlights)仍依 ses_display.story_top_n_per_domain 取前 N 筆;deprecated story_type 等 alias 仍只在前 N 筆裡取分數最高者,值與先前相同。 - suppressed_by 保留在 story 物件上,值為壓過它的 story code(未被壓則為 null)。要不要在 UI 收合由前端決定。 - 此行為與 GET /api/signals/stories/latest 一致——該端點本就顯示同組全部 story。先前兩條路徑對同一筆 ledger 資料的呈現不同(首頁顯示、個股頁隱藏)。

PatternItem.stage: - "watch" — 型態形成中(如 DOUBLE_BOTTOM) - "confirmed" — 型態已確認(如 DOUBLE_TOP、HEAD_SHOULDERS、w_bottom_breakout) - null — 無階段區分

Error Responses: - 404(查無該檔訊號):

{"error": {"code": "No signal data for {ticker}", "message": "No signal data for {ticker}", "details": {}}}

帶 qryDate 時 code/message 為 No signal data for {ticker} on {qryDate}。

  • 422(qryDate 格式錯誤,由 api/helpers.py::parse_date_str 擋下):
{"error": {"code": "Invalid date format. Expected YYYY-MM-DD.", "message": "Invalid date format. Expected YYYY-MM-DD.", "details": {}}}

注意這與 FastAPI 原生的參數驗證 422({"detail": [...]} 陣列)形狀不同,見 §通用 Response 格式。


15. Agent Payloads

這些 endpoint 回傳原始 agent 計算結果,供 MAS 系統或前端進階展示使用。

GET /api/agent/technical/{ticker}

技術面 agent payload。回傳含 scoring、quality flags、penalties、evidence 的結構化資料。

context 的 proximity_to_resistance_ratio、distance_to_resistance_pct、distance_to_resistance_price、is_above_resistance 以 snapshot 同交易日的 stock_ohlc.adj_close 與已確認 adjusted 前高比較。任一比較值缺少、非有限或非正值時,上述衍生欄位為 null,不回退 raw close;原始價格顯示與 raw twins 保持各自基準。

meta 識別欄位同 /api/daily-analysis/{ticker}/latest(issue #571):新增 meta.trading_date,移除 meta.schema_version / meta.timestamp / meta.exchange_tz。

technical_evidence 會同步投影 daily-analysis payload 的 replacement evidence: - ta_index_groups: 同 /api/daily-analysis/{ticker}/latest 的 grouped TA index payload。 - signals / events / stories / relations: 正式 replacement arrays。 - structured_signals / signal_summary: deprecated compatibility alias,來源為 signals / events projection。

支撐族雙基準(issue #1253,migration 125):語意見 §11 支撐族雙基準。本 endpoint 在既有還原基準欄位旁額外 提供 7 個名目基準欄位,既有欄位值與名稱不變:

位置 新增欄位 對應快照欄
context resistance_price_raw major_resistance_raw
context support_20d_price_raw / support_60d_price_raw low_20d_min_raw / low_60d_min_raw
context distance_to_support_20d_pct_raw / _60d_pct_raw / _20d_price_raw / _60d_price_raw 同名 _raw 欄
technical_evidence.price_action distance_to_support_20d_pct_raw / _60d_pct_raw 同名 _raw 欄

scoring、quality flags、penalties 一律沿用還原基準,不受這些欄位影響。

GET /api/agent/chip/{ticker}

籌碼面 agent payload。回傳法人流向、融資券壓力、籌碼證據。

Query Params:

名稱 類型 說明
source_window string 2y(預設)/ all,控制 triggered_rules 回傳對應回測窗口的策略匹配;見 §1 通用說明
cost_window_days integer 法人成本區計算視窗,單位為交易日;預設 60,允許 20 到 252

Replacement 變更: - 新增 triggered_rules: 當天觸發的回測驗證規則,每筆含 metrics_2y + metrics_all 8 項指標(hit_rate, mean_return, expectancy, profit_factor 等)+ support_count - 新增 raw_chip_summary: 原始籌碼資料摘要(z-score, streak, consensus, margin 等事實數據) - meta.cost_window_days: 回傳本次成本區實際採用的交易日視窗。 - context.chip_structure.*_cost_zone: 保留既有外層 exists / band / position(net 視角)並新增: - buy_cost_zone: 只用買超日計算的買入成本區。 - sell_cost_zone: 只用賣超日絕對值計算的賣出成本區。 - net_cost_zone: 舊版 signed net cost zone 的明確巢狀版;若 status=distorted 或 reason=invalid_net_cost,應改讀 buy/sell 兩側成本,不再把外層 position=unknown 解讀成無資料。 - buy_cost_zone.histogram / sell_cost_zone.histogram(v1.6.0): { "prices": number[20], "shares": int[20] } 或 null(無該方向流量、或視窗內收盤全平價)。prices 為視窗收盤 min→max 等分 20 桶的桶中點(所有 actor / 方向共用同一 grid,composite histogram = 各 actor histogram 逐桶相加);shares 為 clip 後的方向性流量股數,sum(shares) == meta.total_shares。新產出的 payload 一律帶此 key;儲存的舊 evidence 缺 key 時會於 request-time 重算。 - indexes.high_volume_support_resistance:價量支撐壓力,原樣回傳 chip evidence 頂層同名區塊(與 chip_structure 同層,不受 cost_window_days 重算影響;欄位為 status、reason、basis、stages、stddev_multiplier、thresholds、window_start、window_end、closing_price、support / support2、resistance / resistance2、previous_support / previous_resistance);本功能上線前寫入的快照為 {},analysis_status = "pending_source"(籌碼來源未到齊)時為 null。 - indexes.institutional_support_resistance:法人支撐壓力,與既有 indexes.support_resistance 為同一物件(別名,兩個 key 皆保留、support_resistance 內容不變);pending_source 時同為 null。 - signals / events / stories / relations: 正式 replacement arrays。 - story_type、story_reasons、chip_story 是 deprecated compatibility alias,來源為 stories projection。 - 移除 top-level schema_version / exchange_tz / timestamp(issue #571,原為每列常數);快照所屬交易日請讀 trading_date。

GET /api/agent/financial/{ticker}

財務面 agent payload。回傳成長維度、估值維度、矩陣位置、故事。

valuation_dimension.components.valuation 內含 dividend 區塊(與 /api/fundamentals/dividend-events 同一 v2 enum shape): { "events": DividendEvent[], "next_ex_dividend_date": date|null, "next_ex_rights_date": date|null }。 個股頁一次 fetch 即取得除權息時間軸;無資料時 events 為空陣列。

Top-level replacement fields: - signals: array<object> - events: array<object> - stories: array<object> - relations: array<object>

financial_story 是 deprecated compatibility alias,來源為 stories projection。

移除 top-level schema_version(issue #571,原為每列常數);快照所屬交易日請讀 trading_date。

GET /api/agent/fundamental/{ticker}

基本面 agent(尚未實作,回傳 "not_ready")。


16. 自選股 Watchlist

Prefix: /api/watchlists — 全部需認證

GET /api/watchlists/groups

列出所有自選股群組。

Response:

[
  {
    "id": "uuid-string",
    "name": "我的觀察清單",
    "display_order": 0
  }
]

POST /api/watchlists/groups

建立自選股群組。

Request Body:

{
  "name": "科技股",            // 1-100 字元
  "display_order": 0           // optional, default 0
}

GET /api/watchlists/groups/{group_id}/items

取得群組內的股票列表。

Query Params:

名稱 類型 預設 說明
market string taiwan OHLC 行情市場
trade_date date latest OHLC 行情查詢日期上限
include_price bool true 是否回傳 items[].price;false 時省略

Response:

[
  {
    "id": "uuid-string",
    "group_id": "uuid-string",
    "ticker": "2330.TW",
    "display_order": 0,
    "note": "主力持股",
    "added_at": "2024-03-15T10:00:00",
    "latest_ohlc": {
      "date": "2026-05-14",
      "open": 850.0,
      "high": 860.0,
      "low": 845.0,
      "close": 855.0,
      "volume": 25000000
    },
    "price": {
      "as_of_date": "2026-05-14",
      "close": 855.0,
      "change": 15.0,
      "change_pct": 1.7857,
      "five_day_close": 800.0,
      "five_day_change": 55.0,
      "five_day_change_pct": 6.875,
      "twenty_day_close": 780.0,
      "twenty_day_change": 75.0,
      "twenty_day_change_pct": 9.6154
    }
  }
]

latest_ohlc 若無完整 OHLC 資料則為 null。price 若無最新 close 資料則為 null;若只有部分回看資料不足,price 內對應欄位為 null。


POST /api/watchlists/groups/{group_id}/items

新增股票到群組。

Request Body:

{
  "ticker": "2330.TW",        // 1-20 字元
  "display_order": 0,          // optional
  "note": "觀察中"             // optional
}

DELETE /api/watchlists/groups/{group_id}

刪除群組。

DELETE /api/watchlists/groups/{group_id}/items/{ticker}

從群組移除股票。

GET /api/watchlists/membership/{ticker}

查詢某股票屬於哪些群組。

GET /api/watchlists/groups/{group_id}/advice

觀察清單版每日操作建議(不使用成本資訊)。對群組內每檔個股回傳 consider_buy(考慮買進)或 watch(持續觀察),由規則引擎依技術/籌碼/財務三維快照計算 composite 分數,疊加主動式 ETF 動向與大盤 regime 守門(bear_stress / range_stress 時禁止 consider_buy)。權重與門檻externalized 於 config/core.yaml advice: 區塊。

  • 群組不屬於當前 member → 404。
  • 非台股 ticker(無 .TW / .TWO 後綴)→ 固定 watch,evidence unsupported_market(v1 僅支援台股)。
  • 任一維度缺快照 → 權重重新正規化並在 evidence 標註 missing_data,不回 500。

Response

{
  "advices": [
    {
      "ticker": "2330.TW",
      "action": "consider_buy",
      "composite": 0.72,
      "scores": {"technical": 0.8, "chip": 0.6, "financial": 0.5},
      "evidence": [{"rule": "composite_add", "value": {"composite": 0.72}}],
      "strategies": ["trend_follow"],
      "close": 1085.0,
      "as_of": {"technical": "2026-07-15", "chip": "2026-07-15", "financial": "2026-07-15"}
    }
  ],
  "market": {"regime_label": "bull_normal", "index_close": 23500.5, "evidence": []},
  "as_of": {"market": "2026-07-15", "market_chip": "2026-07-15", "market_regime": "2026-07-15", "etf": "2026-07-15"},
  "disclaimer": "僅供參考,不構成投資建議"
}

17. 投資組合 Portfolio

Prefix: /api/portfolio — 除 GET /api/portfolio/config 外全部需認證

GET /api/portfolio/open-lots

查詢某 ticker 的未關閉買入批次,給賣出表單的批次選擇器使用。依 FIFO 順序 (transaction_date ASC, created_at ASC, id ASC) 回傳。

Query Parameters: - ticker (必填): 股票代碼,會自動轉大寫。

Response: List[OpenLot]

[
  {
    "buy_transaction_id": "uuid",
    "transaction_date": "2026-04-01",
    "buy_price_per_share": 800.0,        // 原始買入價
    "effective_cost_per_share": 800.1,   // (price*qty + fee + tax) / qty
    "original_qty": 1000,
    "remaining_qty": 700                 // original_qty - 已被 closures 扣除
  }
]

Semantics: - 只回傳 remaining_qty > 0 的買入;已全關的 lot 不出現。 - 前端用 remaining_qty 判斷可賣量,用 effective_cost_per_share 顯示成本基準。 - 沒有任何開放 lot 時回傳 [],不是 404。

GET /api/portfolio/config

回傳前端渲染交易表單時需要的系統設定(目前僅包含台股證交稅率)。無需認證。值由 config/core.yaml 的 portfolio.tax_rates 決定,前端不得 hardcode 稅率。

Response:

{
  "standard_sell_rate": 0.003,    // 一般現股賣出證交稅 0.3%
  "day_trade_sell_rate": 0.0015   // 當沖賣出優惠證交稅 0.15%
}

Invariants: - 0 < day_trade_sell_rate < standard_sell_rate < 1 - 兩個值型別皆為 float

POST /api/portfolio/day-trades

建立一筆當沖交易(day-trade pair)。Backend 原子地寫入 2 筆 transactions (buy leg + sell leg) 與 1 筆 transaction_closures 建立兩者的關聯,全部共用同一個 day_trade_pair_id。

注意: 當沖是產品層的簡化型別,不是原始成交流水。每一組買賣為一筆配對紀錄,不支援分批成交或券商匯入對帳。券商匯入走 POST /api/portfolio/transactions 的一般買/賣路徑。

Request Body:

{
  "ticker": "2330.TW",
  "transaction_date": "2026-04-10",     // YYYY-MM-DD 或 YYYYMMDD
  "direction": "long_first",            // long_first (先買後賣現股) | short_first (先賣後買融券)
  "buy_price": 800.0,                   // > 0
  "sell_price": 810.0,                  // > 0
  "quantity": 1000,                     // > 0
  "fee_buy": 0,                         // >= 0, default 0
  "fee_sell": 0,                        // >= 0, default 0
  "tax_buy": 0,                         // >= 0, default 0
  "tax_sell": null,                     // null → 自動套用 day_trade_sell_rate;0 視為使用者明確設定不覆寫
  "note": "optional",
  "broker_id": "optional-uuid"
}

Response:

{
  "pair_id": "uuid",
  "buy_transaction_id": "uuid",
  "sell_transaction_id": "uuid",
  "closure_id": "uuid",
  "realized_pnl": 9835.0,               // (sell - buy) * qty - fee_buy - fee_sell - tax_buy - tax_sell
  "tax_sell_applied": 165.0             // 實際套用的賣出證交稅(自動或使用者指定)
}

Semantics: - direction = long_first → backend 先 INSERT buy leg,再 INSERT sell leg(best-effort,兩筆 INSERT 可能落在同一個 CURRENT_TIMESTAMP tick 上)。Leg 身份以 day_trade_direction 欄位為準,不應依賴 created_at 排序。 - direction = short_first → 先 INSERT sell leg,再 INSERT buy leg(同上 best-effort 說明)。 - tax_sell 省略或設為 null → 自動計算 sell_price * quantity * day_trade_sell_rate(見 GET /api/portfolio/config)。 - tax_sell 明確設為 0 → 以使用者設定為準,不自動覆寫。 - 當沖的 sell_type 固定為 現股(融券當沖尚未支援)。 - 三筆 DB INSERT 在單一 transaction 中執行,全部成功才 commit。任何一步失敗整組 rollback。

PUT /api/portfolio/day-trades/{pair_id}

部分更新一組當沖配對的兩筆 transactions 與 closure。所有欄位都是 optional;未提供的欄位保留原值。

Request Body (all optional, extra fields rejected):

{
  "buy_price": 810.0,
  "sell_price": 820.0,
  "quantity": 500,
  "transaction_date": "2026-04-10",
  "fee_buy": 0,
  "fee_sell": 0,
  "tax_buy": 0,
  "tax_sell": null,
  "note": "更正",
  "broker_id": "uuid"
}

Response:

{
  "pair_id": "uuid",
  "buy_transaction_id": "uuid",
  "sell_transaction_id": "uuid",
  "realized_pnl": 4910.0,
  "tax_sell_applied": 615.0
}

Semantics: - 若 buy_price / sell_price / quantity 任一變動且 tax_sell 未提供 → 依新值與 day_trade_sell_rate 自動重算。 - 若 tax_sell 明確傳入(含 0)→ 以使用者為準。 - 若都沒有變動 price/qty 相關欄位且 tax_sell 未傳 → 保留原 tax_sell。 - quantity 變動會同步更新 transaction_closures.qty。 - 不允許修改 direction:想改方向請 delete 再 create。送 direction 欄位會觸發 422。 - pair 不存在 → 404。pair 結構異常(只找到一 leg)→ 500(資料不一致,需人工介入)。

DELETE /api/portfolio/day-trades/{pair_id}

刪除一組當沖配對。端到端在單一 transaction 中執行:先刪 closure,再刪兩筆 transactions,最後 commit。

Response: {"ok": true}

Errors: - 404 → pair 不存在或不屬於當前使用者。

GET /api/portfolio/brokers

列出券商。

POST /api/portfolio/brokers

新增券商。

Request Body:

{
  "name": "元大證券",           // 1-100 字元
  "fee_rate": 0.001425,        // 手續費率 (≥0)
  "fee_discount": 0.6,         // 折扣 (≥0), default 1.0
  "is_default": false,          // default false
  "note": "網路下單 6 折"       // optional
}

PUT /api/portfolio/brokers/{broker_id}

更新券商(同 POST schema)。


POST /api/portfolio/transactions

建立交易紀錄。

Request Body:

{
  "ticker": "2330.TW",
  "action": "buy",              // "buy" | "sell"
  "sell_type": null,            // optional, required when action="sell"
  "price_per_share": 850.0,     // > 0
  "quantity": 1000,             // > 0
  "fee": 121,                   // default 0
  "tax": 0,                     // default 0
  "transaction_date": "2024-03-15",
  "note": "定期買入",            // optional
  "broker_id": "uuid-string",   // optional
  "closes_lots": [              // optional for sell (see below)
    {"buy_transaction_id": "uuid", "qty": 500}
  ]
}

Response:

{
  "id": "uuid",
  "warning": {
    "transaction_id": "uuid",
    "code": "unmatched_sell_lot",
    "severity": "warning",
    "message": "Insufficient open lots: need 100, available 0",
    "item": {
      "ticker": "3324.TWO",
      "action": "sell",
      "transaction_date": "2026-04-29",
      "quantity": 100
    }
  }
}

warning 只會在 sell 未指定 closes_lots 且 FIFO open lots 不足時出現;一般成功建立仍只回 { "id": "uuid" }。

closes_lots 規範: - closes_lots 只能出現在 action="sell" 的請求,buy + closes_lots → 400。 - Sell 可以省略 closes_lots,backend 會依 FIFO(transaction_date, created_at, id ASC)自動配對當前使用者在同一 ticker 的未關閉買入批次。 - 若顯式指定 closes_lots,每筆 lot 的 buy_transaction_id 必須指向當前使用者、同一 ticker、action='buy' 的未關閉 lot。 - 禁止同一個 buy_transaction_id 在 closes_lots 裡出現兩次(400 duplicate)。 - 顯式 closes_lots 時 sum(closes_lots[*].qty) 必須等於 request 的 quantity,否則 400 sum mismatch。 - 每筆 lot 的 qty 不能超過該 lot 的 remaining_qty(原始 quantity - 已被關閉的 qty)。 - 若未指定 closes_lots 且使用者沒有足夠的 open lot(總可用 < quantity),仍會建立 sell transaction,但不建立 closure,並回傳 warning.code = "unmatched_sell_lot" 供前端提示使用者。該 sell 不會產生成本、realized PnL 或 unrealized PnL;portfolio 的成本與績效只從可配對的 open lots / positive holdings 計算。 - Validate-first: 顯式 closes_lots 的檢查全部在任何 INSERT 之前進行;任一失敗 → DB 完全不變。 - 所有 INSERT(transaction + N × closure)在單一 DB session,單次 commit。

POST /api/portfolio/transactions/batch

批次新增交易紀錄。此 endpoint 供匯入流程一次送多筆交易,缺少 open lots 的 sell 與單筆建立採相同 warning/anomaly 規則。

Request Body:

{
  "transactionList": [
    {
      "ticker": "2330.TW",
      "action": "buy",
      "sell_type": null,
      "price_per_share": 850.0,
      "quantity": 1000,
      "fee": 121,
      "tax": 0,
      "transaction_date": "2024-03-15",
      "note": "券商匯入",
      "broker_id": null
    }
  ]
}

Response:

{
  "created_count": 1,
  "transactions": [{"index": 0, "id": "uuid"}],
  "skipped_count": 0,
  "skipped_duplicates": [],
  "anomaly_count": 0,
  "anomalies": []
}

批次新增規範: - transactionList 至少 1 筆、最多 100 筆;server 會先依 transaction_date 排序,同日 buy 先於 sell,response index 仍指向原 request index。 - 每筆 item 欄位與單筆 POST /api/portfolio/transactions 相同。 - 非重複資料以 request item 的值寫入;DB 既有資料只作為 duplicate / validation 依據,不會覆蓋 request。 - Duplicate 判斷:同會員、同 transaction_date 只是候選範圍;還必須 ticker、action、sell_type、price_per_share、quantity、fee、tax 都在合理 tolerance 內才是 duplicate 候選。price_per_share 允許極小 numeric tolerance(<= 0.000001);fee / tax 允許 1 元內券商匯入 rounding drift。broker_id 相同者優先 match;若沒有相同 broker_id、但忽略 broker_id 後只有一筆候選,也會視為 retry duplicate 並 skip。 - note 不納入 duplicate signature,避免匯入註記文字不同造成重複漏排。 - 同一個 request 內後續 item 與前面已新增或已 match 的 signature 相同,也會 skip。 - Duplicate skip 不是錯誤;response 以 skipped_count / skipped_duplicates 回報。 - 匯入檔可能不是完整成本基礎。Batch item 是 sell、未提供 closes_lots、且 FIFO open lots 不足時,endpoint 仍會建立 sell transaction,但不建立 closure,並以 anomaly_count / anomalies 回報 unmatched_sell_lot。該 sell 不會產生成本、realized PnL 或 unrealized PnL;portfolio 的成本與績效只從可配對的 open lots / positive holdings 計算。 - anomalies[] 欄位包含 index、transaction_id、code、severity、message、item;item 會回傳原 request index、ticker、action、transaction_date、quantity,供前端告知使用者。 - 明確提供 closes_lots 時仍採 validate-first。任一 validation / explicit closure 錯誤時,整批 rollback。


GET /api/portfolio/transactions

列出交易紀錄。每筆 row 回傳的欄位包含 day_trade_pair_id 與 day_trade_direction(若 row 屬於某組當沖配對),前端可依此渲染配對標記並禁用單筆編輯(backend 的 pair guard 亦會拒絕這類 PUT)。

Query Parameters:

Field Type Required Default Notes
limit integer no portfolio.transaction_list.default_limit 回傳筆數;backend clamp 到設定的最大值。
ticker string no null 篩選單一個股交易列表;backend 會正規化為大寫,例如 2330.tw 以 2330.TW 查詢。

範例:

GET /api/portfolio/transactions?ticker=2330.TW&limit=100

不帶 ticker 時維持既有全交易列表 response shape;指定 ticker 且無交易時回傳空陣列 []。

Response (per row):

{
  "id": "sell-warning",
  "ticker": "2330.TW",
  "action": "sell",
  "sell_type": "現股",
  "price_per_share": 800.0,
  "quantity": 1000,
  "fee": 0.0,
  "tax": 0.0,
  "transaction_date": "2026-04-01",
  "note": null,
  "broker_id": null,
  "broker_name": null,
  "day_trade_pair_id": null,
  "day_trade_direction": null,
  "is_warning": true,
  "warning_code": "unmatched_sell_lot",
  "warning_reason": "Sell quantity does not have full matching buy lots; this warning transaction is excluded from holdings and PnL.",
  "matched_quantity": 0,
  "unmatched_quantity": 1000,
  "warning_details": {
    "sell_quantity": 1000,
    "matched_quantity": 0,
    "unmatched_quantity": 1000
  }
}

Semantics(is_warning / warning_code / warning_reason / matched_quantity / unmatched_quantity / warning_details): - 這六個欄位只對 action="sell" 的 row 有意義;action="buy" 的 row 全部固定回傳 is_warning=false、其餘五個欄位皆為 null。 - matched_quantity:該筆 sell 在 transaction_closures 中被關閉的總量(COALESCE(SUM(qty), 0));buy row 為 null。 - unmatched_quantity:max(quantity - matched_quantity, 0);buy row 為 null。 - is_warning:action="sell" 且 unmatched_quantity != 0(完全關閉的 sell、或 buy,皆為 false)。 - warning_code / warning_reason:is_warning=true 時固定為 "unmatched_sell_lot" 與上例的固定文字;否則皆為 null。此 warning 意義與 POST /api/portfolio/transactions 回傳的巢狀 warning(見上方,同樣使用 code="unmatched_sell_lot")相同,但這裡是攤平在 row 上的獨立表示法,非 schema 不一致。 - warning_details:is_warning=true 時回傳 {sell_quantity, matched_quantity, unmatched_quantity} 快照;否則為 null。 - Warning row(is_warning=true)不產生 cost basis、realized PnL 或 unrealized PnL,會被排除在 holdings 與 PnL 計算之外(呼應 warning_reason 內文)。

PUT /api/portfolio/transactions/{transaction_id}

更新交易紀錄(同 POST schema,除 ticker 外)。支援 closes_lots 重新配對(validate-first-replace):

  • 若 request 帶 closes_lots,backend 先驗證新 closures(以「排除本 sell 自己既有 closures」後的 open_lots 視角),通過後在單一 session 內:DELETE FROM transaction_closures WHERE sell_transaction_id=id → INSERT 新 closures → UPDATE transactions → 單次 commit。
  • 若 request 不帶 closes_lots,沿用舊行為只更新 transaction 欄位,closures 不動。
  • closes_lots 的 400 錯誤條件同 POST。

注意: 若目標 transaction 屬於某個當沖配對(day_trade_pair_id 不為 NULL),回傳 400,要求呼叫 PUT /api/portfolio/day-trades/{pair_id} 進行配對編輯。此 pair guard 避免半配對狀態(只改其中一 leg 會讓 closure 失去一致性)。

PUT /api/portfolio/transactions/batch

批次更新交易紀錄。每筆 item 需要帶 id 與完整 update 欄位;語意與單筆 PUT /api/portfolio/transactions/{transaction_id} 相同。

Request Body:

{
  "transactionList": [
    {
      "id": "uuid",
      "action": "buy",
      "sell_type": null,
      "price_per_share": 855.0,
      "quantity": 1000,
      "fee": 121,
      "tax": 0,
      "transaction_date": "2024-03-15",
      "note": "券商匯入修正",
      "broker_id": null
    }
  ]
}

Response:

{
  "updated_count": 1,
  "transactions": [{"index": 0, "id": "uuid"}]
}

批次更新規範: - transactionList 至少 1 筆、最多 100 筆;同一 id 重複出現 → 400。 - Update item 使用全量欄位,不是 partial patch;未帶 required 欄位會回 422。 - 欄位值以 request item 為準;DB 既有 row 只用於存在性、member ownership、pair guard 與 closure validation。 - closes_lots 行為同單筆 PUT:帶入時 validate-first-replace。 - 任一 item 不存在、不屬於目前 member、pair guard 失敗、或 validation 失敗時,整批 rollback。

DELETE /api/portfolio/transactions/{transaction_id}

刪除交易紀錄。

注意: 若目標 transaction 屬於某個當沖配對(day_trade_pair_id 不為 NULL),回傳 400,要求呼叫 DELETE /api/portfolio/day-trades/{pair_id} 進行配對刪除。此 pair guard 避免單腳刪除導致另一 leg 與 closure 關聯孤立。

DELETE /api/portfolio/transactions/batch

批次刪除交易紀錄。

Request Body:

{
  "transactionIds": ["uuid-1", "uuid-2"]
}

Response:

{
  "deleted_count": 2,
  "transaction_ids": ["uuid-1", "uuid-2"]
}

批次刪除規範: - transactionIds 至少 1 筆、最多 100 筆;同一 id 重複出現 → 400。 - 刪除只接受 transaction id,不用日期/價格/數量推測,以避免同日多筆交易時誤刪。 - 任一 id 不存在、不屬於目前 member、或 pair guard 失敗時,整批 rollback。 - 若目標 transaction 屬於當沖配對,回 400,要求呼叫 DELETE /api/portfolio/day-trades/{pair_id}。


GET /api/portfolio/positions/{ticker}

取得個股持倉。

GET /api/portfolio/positions

取得所有持倉。

GET /api/portfolio/holdings

取得持股明細(含成本資訊)。每筆 row 包含 realized_pnl、day_trade_realized_pnl、total_realized_pnl、unrealized_pnl、avg_buy_price、cost_basis_total 等欄位。語意參見下方 Metrics Naming Specification。

每筆 row 另外帶 name(來源 {country}.stocks.name,依 ticker 所屬國別查詢)與 market_price_date。stocks 查無該檔資料、或該檔的名稱為 NULL 時 name 為 null;前端不需再為了顯示名稱逐檔呼叫 GET /api/stocks/{ticker}。market_price_date 是 market_price 同一筆 latest OHLC 的 YYYY-MM-DD 日期;缺少可用價格時兩者均為 null。名稱與日價只對 net_qty > 0 的持股查詢,未持有任何部位時不會發出查詢。

Response(單筆 row,欄位順序即服務端建構順序):

[
  {
    "ticker": "2330.TW",
    "name": "台積電",
    "buy_qty": 10,
    "sell_qty": 0,
    "stock_dividend_qty": 0,
    "net_qty": 10,
    "avg_buy_price": 100.0,
    "buy_amount": 1000.0,
    "sell_amount": 0.0,
    "cost_basis_total": 1000.0,
    "market_price": 120.0,
    "market_price_date": "2026-10-02",
    "realized_pnl": 0.0,
    "day_trade_realized_pnl": 0.0,
    "total_realized_pnl": 0.0,
    "unrealized_pnl": 200.0,
    "cash_dividend_amount": 0.0
  }
]

GET /api/portfolio/advice

持股版每日操作建議(使用真實成本)。對每檔未平倉持股(position_qty > 0,已平倉部位排除)回傳一個 action + 逐條 evidence:

action 觸發條件(first-match 順序)
stop_loss 高點回檔 ≥ 30%(drawdown_stop_loss_pct),或(use_invalidation_stop 開啟時)收盤嚴格低於已確認前低 invalidation_level(缺值時僅使用既有 structure_stop;兩者皆缺值就不觸發此分支,不使用 ATR 替代)
take_profit 未實現獲利 ≥ 20%(take_profit_pnl_pct)且 composite ≤ 轉弱門檻
reduce 高點回檔 ≥ 15%(drawdown_reduce_pct)
add composite ≥ 加碼門檻 且 大盤 regime 放行 且 距 major_resistance 有足夠空間
hold 其餘
  • 高點定義:自進場(最早未平倉 lot 的 transaction_date)以來 stock_ohlc.adj_close(還原價)最高收盤價,與最新收盤取 max;回檔 = (peak − close) / peak。
  • 決策價格(#1746):停損、回檔與壓力空間比較採持股估值所用的最新有效原價 OHLC 列之 adj_close;當日還原價缺少或無效時不回退原價或更早的還原價,回傳 hold 與 missing_data,drawdown_pct 為 null。回傳的 close 仍是持股原價估值,成本與未實現損益沿原有計算。
  • 還原價基準(issue #1253):高點自 #1253 起改讀 adj_close。原本的分段峰值(公司行動修正) workaround 已退役——該 workaround 是為了原始價序列在分割/大額除權當日的斷崖(會把獲利部位誤判成 stop_loss,issue #941);還原序列本身沒有斷崖,且在還原基準上僅存的單日暴跌都是真實崩跌,再做分段只會把該報的回撤藏起來。連帶移除:evidence 規則 corporate_action_adjusted(含 peak_basis_date、cliff_day_drop_ratio)不再出現;drawdown_cliff_ratio / drawdown_cliff_max_gap_days 不再影響高點。無還原價的 K 棒直接略過、不以原始價替代(混基準的高點比沒有高點更糟);個股若尚無任何還原價資料則無高點,回 missing_data。
  • 優先序為刻意設計:composite 強多但回檔落在 [15%, 30%) 區間時 action 為 reduce 非 add(保護獲利優先於訊號強度),evidence 同時附 drawdown 與 composite 值。
  • composite 計算、ETF 動向疊加、大盤守門、非台股與缺資料降級行為同觀察清單版(見 §16)。
  • 所有權重/門檻在 config/core.yaml advice: 區塊。

Response:結構同觀察清單版,每筆 advice 額外含 position_qty、avg_buy_price(含手續費成本)、unrealized_pnl_pct、drawdown_pct。

GET /api/portfolio/allocation

依股票分類彙總目前仍持有的部位(position_qty > 0),用於呈現類股投資總額與配置比例。

Query Parameters:

Field Type Required Default Notes
type Group | ETF no Group Group 依產業分類;ETF 依 ETF/個股分類。

分類規則: - type=Group:優先使用 stocks.industry,與既有族群/產業分析的 industry-first 慣例一致。 - type=Group 且 industry 缺失時,改用 stocks.sector。 - type=Group 且 stocks 查無資料或 industry / sector 都是空值,歸入 Unclassified。 - type=ETF:使用 etf_funds.etf_ticker 判斷 ETF,回傳 etf 與 stock 兩種 bucket。Response format 不變。

Response:

{
  "total_market_value": 11500.0,
  "total_cost_basis": 11000.0,
  "groups": [
    {
      "category": "Semiconductors",
      "category_source": "industry",
      "market_value": 10500.0,
      "cost_basis_total": 10000.0,
      "allocation_pct": 91.3043,
      "holdings_count": 2,
      "tickers": ["2330.TW", "8299.TWO"]
    },
    {
      "category": "Insurance",
      "category_source": "industry",
      "market_value": 1000.0,
      "cost_basis_total": 1000.0,
      "allocation_pct": 8.6957,
      "holdings_count": 1,
      "tickers": ["2881.TW"]
    }
  ]
}

type=ETF 範例:

{
  "total_market_value": 4500.0,
  "total_cost_basis": 4100.0,
  "groups": [
    {
      "category": "stock",
      "category_source": "asset_type",
      "market_value": 2700.0,
      "cost_basis_total": 2500.0,
      "allocation_pct": 60.0,
      "holdings_count": 2,
      "tickers": ["2330.TW", "8299.TWO"]
    },
    {
      "category": "etf",
      "category_source": "asset_type",
      "market_value": 1800.0,
      "cost_basis_total": 1600.0,
      "allocation_pct": 40.0,
      "holdings_count": 1,
      "tickers": ["0050.TW"]
    }
  ]
}

Semantics: - market_value = market_price * position_qty;若該 ticker 沒有可用市價,使用 cost_basis_total 作為 fallback,避免缺價持股從配置中消失。 - allocation_pct 以 market_value / total_market_value * 100 計算,四捨五入到小數第 4 位。 - total_cost_basis 為所有 active holdings 的成本基礎加總,可作為「投入成本」口徑。 - groups 依 market_value DESC, category ASC 排序;每個 group 的 tickers 依 ticker ASC 排序。 - 空投資組合回傳 {"total_market_value": 0.0, "total_cost_basis": 0.0, "groups": []}。

GET /api/portfolio/performance

取得目前使用者持股的未實現績效彙總。總未實現損益沿用 open lots 成本基準;day / month / quarter / year 一律以目前 close 對該期間開始前最後一個可用交易日 close——分別是前一個交易日、上月最後一個交易日、上一季最後一個交易日、去年最後一個交易日。quarter 為日曆季至今(quarter-to-date):季起日為 as_of 所在季度的第一天(1/1、4/1、7/1、10/1),baseline 取該日之前最後一個可用交易日 close,例如 as_of 2026-06-22 的季 baseline 為 2026-03-31。若某檔在期間開始之後才上市、期間開始前沒有任何收盤,則退回該期間內最早一個可用交易日 close。*_pnl_pct 單位為百分點(例如 9.09 代表 9.09%)。

Query Parameters:

Field Type Required Default Notes
ticker string no null 股票代碼;未提供時彙總所有 open holdings,提供時只計算該個股。會自動轉大寫。
type Group | ETF no null 新增分組期間績效;Group 依產業分類,ETF 依 ETF/個股分類。不可與 ticker 同時使用。
tickers string no null 逗號分隔的批次代碼清單;每項去空白、轉大寫、依首次出現順序去重。上限由 portfolio.performance.batch_max_tickers 設定(預設 100)。與 ticker、type 互斥。原始字串長度上限為 (20+1)×batch_max_tickers = 2100 字元(含分隔逗號與空白),超過由參數驗證擋下。

Request modes:

  • 不帶 ticker / type:回傳整體投資組合期間績效,response shape 維持既有格式。
  • 帶 ticker:回傳單一 ticker 期間績效,response shape 維持既有格式。
  • type=Group:回傳產業分組期間績效;分類口徑比照 GET /api/portfolio/allocation?type=Group,優先 stocks.industry,缺失時用 stocks.sector,仍缺失時為 Unclassified。
  • type=ETF:回傳 ETF / 個股分組期間績效;分類口徑比照 GET /api/portfolio/allocation?type=ETF,分類為 etf / stock。
  • 帶 tickers:一次回傳多檔的逐檔期間績效,items 每個值就是該檔 ?ticker= 的 response(同一份 shape),整個請求只跑一次 portfolio metrics、一次最新價查詢與一次 baseline 查詢(每個查詢各自對每個市場/country 跑一次),查詢次數只隨市場數變動、不隨檔數增加。items 的 key 順序=正規化去重後的請求順序;頂層 as_of_date 是所有 item 中最大的 as_of_date(全部未持有時為 null)。未持有的 ticker 回傳 positions_count: 0、各 period 全 null 的單檔 shape,不是錯誤。
  • ticker 與 type 同時提供會回 HTTP 422。
  • tickers 與 ticker 或 type 同時提供會回 HTTP 422(tickers cannot be used together with ticker or type.)。
  • tickers 去空白去重後為空(例如 tickers=,,)會回 HTTP 422(tickers must contain at least one ticker.)。
  • tickers 去重後超過上限會回 HTTP 422(tickers accepts at most 100 tickers.)。三種 422 都在任何 DB 查詢之前擋下。

若某期間任一 open holding 缺少 current 或 baseline close,該期間的 baseline_date、baseline_value、unrealized_pnl、unrealized_pnl_pct 皆回傳 null;總未實現欄位仍會依既有 portfolio metrics 行為回傳。

Response:

{
  "as_of_date": "2026-06-22",
  "ticker": null,
  "positions_count": 2,
  "market_value": 2400.0,
  "cost_basis_total": 2000.0,
  "total_unrealized_pnl": 400.0,
  "total_unrealized_pnl_pct": 20.0,
  "periods": {
    "day": {
      "label": "day",
      "baseline_date": "2026-06-19",
      "baseline_value": 2420.0,
      "unrealized_pnl": -20.0,
      "unrealized_pnl_pct": -0.826446
    },
    "month": {
      "label": "month",
      "baseline_date": "2026-05-29",
      "baseline_value": 2200.0,
      "unrealized_pnl": 200.0,
      "unrealized_pnl_pct": 9.090909
    },
    "quarter": {
      "label": "quarter",
      "baseline_date": "2026-03-31",
      "baseline_value": 1950.0,
      "unrealized_pnl": 450.0,
      "unrealized_pnl_pct": 23.076923
    },
    "year": {
      "label": "year",
      "baseline_date": "2025-12-31",
      "baseline_value": 1700.0,
      "unrealized_pnl": 700.0,
      "unrealized_pnl_pct": 41.176471
    }
  }
}

單一個股範例:

GET /api/portfolio/performance?ticker=2330.TW
{
  "as_of_date": "2026-06-22",
  "ticker": "2330.TW",
  "positions_count": 1,
  "market_value": 1200.0,
  "cost_basis_total": 1000.0,
  "total_unrealized_pnl": 200.0,
  "total_unrealized_pnl_pct": 20.0,
  "periods": {
    "day": {
      "label": "day",
      "baseline_date": "2026-06-19",
      "baseline_value": 1180.0,
      "unrealized_pnl": 20.0,
      "unrealized_pnl_pct": 1.694915
    },
    "month": {
      "label": "month",
      "baseline_date": "2026-05-29",
      "baseline_value": 1100.0,
      "unrealized_pnl": 100.0,
      "unrealized_pnl_pct": 9.090909
    },
    "quarter": {
      "label": "quarter",
      "baseline_date": "2026-03-31",
      "baseline_value": 1050.0,
      "unrealized_pnl": 150.0,
      "unrealized_pnl_pct": 14.285714
    },
    "year": {
      "label": "year",
      "baseline_date": "2025-12-31",
      "baseline_value": 900.0,
      "unrealized_pnl": 300.0,
      "unrealized_pnl_pct": 33.333333
    }
  }
}

批次逐檔績效範例(tickers):9999.TW 未持有,回空 period 的單檔 shape。 注意本節所有範例的 *_pnl_pct 為未四捨五入的浮點數(既有範例為省略寫法,實際 response 會帶完整精度)。

GET /api/portfolio/performance?tickers=2330.TW,2317.TW,9999.TW
{
  "as_of_date": "2026-06-22",
  "items": {
    "2330.TW": {
      "as_of_date": "2026-06-22",
      "ticker": "2330.TW",
      "positions_count": 1,
      "market_value": 1200.0,
      "cost_basis_total": 1000.0,
      "total_unrealized_pnl": 200.0,
      "total_unrealized_pnl_pct": 20.0,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": "2026-06-19",
          "baseline_value": 1180.0,
          "unrealized_pnl": 20.0,
          "unrealized_pnl_pct": 1.694915254237288
        },
        "month": {
          "label": "month",
          "baseline_date": "2026-05-29",
          "baseline_value": 1100.0,
          "unrealized_pnl": 100.0,
          "unrealized_pnl_pct": 9.090909090909092
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": "2026-03-31",
          "baseline_value": 1050.0,
          "unrealized_pnl": 150.0,
          "unrealized_pnl_pct": 14.285714285714285
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 900.0,
          "unrealized_pnl": 300.0,
          "unrealized_pnl_pct": 33.33333333333333
        }
      }
    },
    "2317.TW": {
      "as_of_date": "2026-06-22",
      "ticker": "2317.TW",
      "positions_count": 1,
      "market_value": 1200.0,
      "cost_basis_total": 1000.0,
      "total_unrealized_pnl": 200.0,
      "total_unrealized_pnl_pct": 20.0,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": "2026-06-19",
          "baseline_value": 1240.0,
          "unrealized_pnl": -40.0,
          "unrealized_pnl_pct": -3.225806451612903
        },
        "month": {
          "label": "month",
          "baseline_date": "2026-05-29",
          "baseline_value": 1100.0,
          "unrealized_pnl": 100.0,
          "unrealized_pnl_pct": 9.090909090909092
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": "2026-03-31",
          "baseline_value": 900.0,
          "unrealized_pnl": 300.0,
          "unrealized_pnl_pct": 33.33333333333333
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 800.0,
          "unrealized_pnl": 400.0,
          "unrealized_pnl_pct": 50.0
        }
      }
    },
    "9999.TW": {
      "as_of_date": null,
      "ticker": "9999.TW",
      "positions_count": 0,
      "market_value": 0.0,
      "cost_basis_total": 0.0,
      "total_unrealized_pnl": 0.0,
      "total_unrealized_pnl_pct": null,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": null,
          "baseline_value": null,
          "unrealized_pnl": null,
          "unrealized_pnl_pct": null
        },
        "month": {
          "label": "month",
          "baseline_date": null,
          "baseline_value": null,
          "unrealized_pnl": null,
          "unrealized_pnl_pct": null
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": null,
          "baseline_value": null,
          "unrealized_pnl": null,
          "unrealized_pnl_pct": null
        },
        "year": {
          "label": "year",
          "baseline_date": null,
          "baseline_value": null,
          "unrealized_pnl": null,
          "unrealized_pnl_pct": null
        }
      }
    }
  }
}

分組績效範例(type=Group):

GET /api/portfolio/performance?type=Group
{
  "type": "Group",
  "as_of_date": "2026-06-22",
  "positions_count": 3,
  "market_value": 2750.0,
  "cost_basis_total": 2250.0,
  "total_unrealized_pnl": 500.0,
  "total_unrealized_pnl_pct": 22.2222,
  "groups": [
    {
      "category": "Semiconductors",
      "category_source": "industry",
      "positions_count": 1,
      "market_value": 1200.0,
      "cost_basis_total": 1000.0,
      "total_unrealized_pnl": 200.0,
      "total_unrealized_pnl_pct": 20.0,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": "2026-06-19",
          "baseline_value": 1180.0,
          "unrealized_pnl": 20.0,
          "unrealized_pnl_pct": 1.6949
        },
        "month": {
          "label": "month",
          "baseline_date": "2026-05-29",
          "baseline_value": 1100.0,
          "unrealized_pnl": 100.0,
          "unrealized_pnl_pct": 9.0909
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": "2026-03-31",
          "baseline_value": 1050.0,
          "unrealized_pnl": 150.0,
          "unrealized_pnl_pct": 14.2857
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 900.0,
          "unrealized_pnl": 300.0,
          "unrealized_pnl_pct": 33.3333
        }
      },
      "tickers": ["2330.TW"]
    }
  ]
}

分組績效範例(type=ETF):

GET /api/portfolio/performance?type=ETF
{
  "type": "ETF",
  "as_of_date": "2026-06-22",
  "positions_count": 2,
  "market_value": 2200.0,
  "cost_basis_total": 1800.0,
  "total_unrealized_pnl": 400.0,
  "total_unrealized_pnl_pct": 22.2222,
  "groups": [
    {
      "category": "stock",
      "category_source": "asset_type",
      "positions_count": 1,
      "market_value": 1200.0,
      "cost_basis_total": 1000.0,
      "total_unrealized_pnl": 200.0,
      "total_unrealized_pnl_pct": 20.0,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": "2026-06-19",
          "baseline_value": 1180.0,
          "unrealized_pnl": 20.0,
          "unrealized_pnl_pct": 1.6949
        },
        "month": {
          "label": "month",
          "baseline_date": "2026-05-29",
          "baseline_value": 1100.0,
          "unrealized_pnl": 100.0,
          "unrealized_pnl_pct": 9.0909
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": "2026-03-31",
          "baseline_value": 1050.0,
          "unrealized_pnl": 150.0,
          "unrealized_pnl_pct": 14.2857
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 900.0,
          "unrealized_pnl": 300.0,
          "unrealized_pnl_pct": 33.3333
        }
      },
      "tickers": ["2330.TW"]
    },
    {
      "category": "etf",
      "category_source": "asset_type",
      "positions_count": 1,
      "market_value": 1000.0,
      "cost_basis_total": 800.0,
      "total_unrealized_pnl": 200.0,
      "total_unrealized_pnl_pct": 25.0,
      "periods": {
        "day": {
          "label": "day",
          "baseline_date": "2026-06-19",
          "baseline_value": 980.0,
          "unrealized_pnl": 20.0,
          "unrealized_pnl_pct": 2.0408
        },
        "month": {
          "label": "month",
          "baseline_date": "2026-05-29",
          "baseline_value": 950.0,
          "unrealized_pnl": 50.0,
          "unrealized_pnl_pct": 5.2632
        },
        "quarter": {
          "label": "quarter",
          "baseline_date": "2026-03-31",
          "baseline_value": 880.0,
          "unrealized_pnl": 120.0,
          "unrealized_pnl_pct": 13.6364
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 800.0,
          "unrealized_pnl": 200.0,
          "unrealized_pnl_pct": 25.0
        }
      },
      "tickers": ["0050.TW"]
    }
  ]
}

Grouped Response Semantics:

  • groups[].periods 的 day/month/quarter/year 計算口徑與既有整體/單 ticker performance 相同。
  • grouped top-level market_value、cost_basis_total、total_unrealized_pnl、positions_count 為各 group 加總。
  • grouped top-level as_of_date 為 groups 中最大 as_of_date;若無 active holdings 則為 null。
  • groups 依 market_value DESC, category ASC 排序;每個 group 的 tickers 依 ticker ASC 排序。
  • 空投資組合回傳 type、zero totals 與 groups=[]。

GET /api/portfolio/investment-performance

完整投資操作績效。與既有 /api/portfolio/performance(只看目前持倉市場變化)分開。

Query Parameters:

參數 類型 必填 預設 說明
period all/day/week/month/quarter/year 否 all 已實現依賣出/除息日歸期;其他指標限制見下文
value string non-all 是 null day YYYYMMDD/ISO;week YYYY-Www;month YYYYMM/YYYY-MM;quarter compact/hyphen;year YYYY
ticker string 否 null 可查已清倉 ticker;與 type 互斥
type Group/ETF 否 null current metadata classification

Response metrics:

  • realized.regular_pnl/day_trade_pnl/cash_dividend/total
  • expected_cash_dividend(不進 realized/total)
  • unrealized_pnl/total_pnl/capital_employed/return_pct/equity_multiple
  • opened_lots_count/closed_lots_count
  • data_quality: 與本次 scope/metric 相關的 unmatched sells、missing prices、standalone/unattributed dividends,以及必填非 null 的 calculation_issues;四個 coverage 欄位僅為 deprecated compatibility fields(固定 true/[])

dividend_history_complete、dividend_missing_coverage、dividend_schedule_complete、 dividend_schedule_missing_coverage 是 deprecated compatibility fields,固定回傳 true/[]。不再查詢每月 historical coverage 或每日 schedule checkpoint, 也不以這些欄位證明來源完整;來源採集/漏載須由來源層另行稽核。缺少或過期的 月度/每日紀錄不得在沒有實際計算問題時使績效查詢不完整。

除息日尚未到查詢有效截止日的現金股利,單純金額缺值或公告金額衝突不產生 unquantified_cash_dividend/conflicting_dividend_event 或績效不完整警示;expected_cash_dividend 仍保留可量化的預估合計,不捏造未知或衝突金額。到達除息日且當時有有效持股、 仍缺必要金額或有衝突時,才在已實現股利相關指標揭露。此規則共用於 overview、details、groups 與 timeseries;已發生交易的未配對等真實計算問題仍會揭露。

Overview 的 Legacy data_quality.is_complete 由本次回應真正相關的 calculation issues 決定;沒有相關計算問題時為 true。Overview 沒有 reconciliation 欄位。Details 另以同 一次 audit 的 reconciliation.status 驗證 aggregate 與完整明細:matched 才表示對帳 成功,incomplete/mismatch 均使 details 相關範圍不完整。未知 issue code 不得被忽略, 而 deprecated coverage 欄位不參與判定。calculation_issues 只列本次 audit 已知計算 問題;空陣列表示未偵測到已知計算問題,不代表外部來源已驗證完整。每筆 issue 必含 code:string、count:integer、 tickers:string[]、affected_metrics:PerformanceDetailMetric[]。初始 code 為 unmatched_sell、missing_price、unattributed_cash_dividend,consumer 必須容忍未知 code;coverage checkpoint 不會成為 calculation issue。Details 會按所選 metric 過濾 issues 及 unmatched/standalone/unattributed/missing-price 的 count 與 tickers; 無關問題不得污染該 metric,尤其 day_trade_pnl 不受無關股利或價格問題影響。後端固定 提供這四個相容欄位的既有 shape,足以支援原 frontend consumer;本次不要求任何 Web/Management/Mobile frontend change。

count 單位:missing_price 為不同缺價標的檔數(等於 len(tickers)); unmatched_sell 為該 affected-metrics bucket 的賣出交易筆數; unattributed_cash_dividend 為無法歸屬的現金股利交易筆數。 不同 code 的 count 不可直接相加當作總交易筆數。

非 all 的操作 cohort 只計入期間內開倉 lots;realized regular P&L 以 sell-date 實際 allocated original cost 計算,realized dividend 則依 ex-date 的完整 qualifying holdings,非期間 cohort。expected dividend 以 period-end holdings 計算,不受 stale quote 阻擋。ex_date <= end_date(圖表點為 period_end)即依除息權利認列 realized,不受最新股價日期落後影響;歷史 entitlement 使用 ex-date 前 transactions/closures。非 all 的 U/K/total/return composition 仍未定案,尚非 merge-ready。

Grouped response 為 type/classification_basis/overall/groups/data_quality;groups[] 另有 category/category_source/tickers。groups[].tickers 為本次有明細或未配對賣出的標的:期間內賣出、期間內除息、當前預計股利,以及期間內開倉 lots;賣出/股利可以來自舊持股,不受開倉 cohort 限制。非分類的全歷史成員;period=all 等同全部成員。完整 schema 與 example 請參考 Frontend integration SSOT。

GET /api/portfolio/investment-performance/details

回傳單一操作績效 metric 的可對帳逐筆明細。必填 metric:regular_pnl/day_trade_pnl/cash_dividend/expected_cash_dividend/realized_total/unrealized_pnl/capital_employed/opened_lots_count/closed_lots_count/total_pnl。

period/value/ticker 沿用單期 API。分類 drill-down 必須同時傳 type/category/category_source,且與 ticker 互斥;backend 以 current metadata 驗證分類並解析 tickers。offset 預設 0;limit 預設 100、最大 1000。

Response 包含 window/scope/metric/unit/value/reconciliation/pagination/items/data_quality。value 是權威 aggregate;detail_value 由未分頁的完整明細計算,前端不可 sum 當頁 items。capital_employed 對帳 basis 為 max_capital_required,不是 sum。所有 items 均依主要日期由新到舊;capital-flow cumulative/peak 仍按正向時間計算,只反轉輸出排列。Items 以 item_type=closure/dividend/lot/capital_flow 判別;mixed realized_total/total_pnl 會包含多種 item。完整欄位、範例、null 與錯誤處理見 Frontend integration SSOT。

reconciliation 只驗證同一次 audit 的 aggregate 與 details,matched 不會清除 calculation_issues。Details 的 data_quality 已按所選 metric 過濾。 mismatch/incomplete 亦使後端回 is_complete=false,即使 issue 陣列為空。 完整性描述所選數值及全量對帳,不保證 items 的附帶欄位:例如開倉筆數可正確而 lot 估值為 null,或未配對賣出下剩餘量/status 暫定;估值品質須看未實現/總損益。

GET /api/portfolio/investment-performance/timeseries

Query: start_date、end_date(不可晚於今日)、interval=day|week|month|quarter|year,以及互斥的 ticker / type。

series[].points[] 同時包含 period(bucket 的 sell-date/ex-date 已實現與仍待定案的其他指標)及 cumulative(inception 至 point period_end)。日期點取實際報價日、非現金股利交易日、來源除權息日,以及核心分類為真正 standalone orphan 的付款日聯集;不為已歸屬或 ambiguous 的付款日建立股利日期點。point valuation_date 為最後活動日,價格只取該日以前最新已知 close,不產生虛構報價。歷史點不倒灌未來賣出或 expected dividend;已歸屬的晚記錄付款可作為原 ex-date 金額證據,不改歸期。總點數超過 2000 回 HTTP 422。

GET /api/portfolio/summary

投資組合總覽(含總市值、損益等)。

股利欄位 shape 不變,OpenAPI metadata 亦不變。摘要使用以 canonical asset identity 與 除權息日識別事件的共用股利引擎:先在完整同資產事件時間線建立現金入帳的 ownership,再 依年度篩選事件;不得以「下一個 cash ex-date」截斷舊付款範圍,因較早事件可能在較晚 ex-date 後才入帳。在依期間篩選之前,先處理相同 ex-date 的有效 payment bundle;其他 payment 只有在恰有一個較早、尚未 claim 的 eligible cash event,且該事件恰有一個未配對 candidate payment 時才可歸屬,不以金額相等或 nearest guess 推定。所有較早且仍可能相關的未 claim event 都保留為候選;已證明 stock-only 的事件排除,unknown announcement、conflicting event 或 uncertain holdings 會阻止 false unique claim。

唯一歸屬的 payment 可一次 override 回原 ex-date(不改 payment 原始日期,也不在支付月份重列)。 同 ticker/ex-date 的 manual cash-dividend transaction 仍優先且不重複加總。event、明細與 issue 使用一致的 display ticker;raw payment evidence 保留原始 ticker,配對仍使用 canonical asset identity。ex_date <= 台北今日 的 received event 使用除息日前歷史 entitlement;已知有效的 partial closure 先扣除已證明股數,未知殘量不被假裝已解決。pending event 維持使用台北今日 transactions/closures 的 current holdings。例:兩個較早事件 entitlement 為 200 與 100、 晚到 payment 為 200 時,不得任意選一事件;兩個事件維持 inferred 300、manual_received_amount=0, payment 留在 raw unattributed_payments 並產生相應 issue。

同一天的 payment bundle 以整體核對:只要其中任一筆的 net amount <= 0,整個 bundle 保留於 raw invalid_payments evidence,不能 override 有效 event,也不能新增 manual income;有效 event entitlement 仍維持推算值。invalid_payments 與 unattributed_payments 互斥;同一 raw row 只計一次 invalid issue,不重複放入 unattributed。audit.unattributed_payments 是所有其餘 未消費 payment evidence 的原始集合;audit.standalone_payments 是可進 manual bucket 的 子集。與已知 cash event 相關、但因多筆候選而無法唯一歸屬的 payments 只留在 unattributed,且不會進 realized performance 或在 inferred event 之外再算一筆 manual income;沒有未占用候選(含事件已被 同日款占用)的有效 orphan/manual payment,以及 zero-entitlement 的 orphan,則在 shared audit 的 realized_dividends 保留一次,attribution=standalone、event_key=null、 explicit_override=false。此 standalone item 的既有 ex_dividend_date 欄位僅表示 payment date,不是已確認的 ex-date;其金額同時納入既有 summary manual_received_amount bucket。 歧義可為多個事件候選、單一事件對多筆付款或資格/身份未確定,保留 unattributed issue; invalid payment 僅帶 invalid issue,兩者均不另算 manual income。summary manual bucket 仍依 payment date 篩選且沒有 quality 欄位,不宣稱確認了 ex-date。上述均為 audit/歸屬規則, 不新增 public wire 欄位,也不宣稱完整績效資料。

年度仍是 Asia/Taipei 今日所屬年度,沒有新的 year query parameter。跨年付款仍按 ex-date 歸屬:例如 ex-date 在 2025、payment 在 2026 時,2026 summary 的 received/total 為 0, 原 2025 event 才包含實際入帳(例如 173),不按 payment year 重列。

Response:

{
  "positions_count": 3,
  "net_shares": 1500,
  "total_buy_amount": 150000.0,
  "total_sell_amount": 80000.0,
  "net_cash_flow": -70000.0,
  "realized_pnl": 3200.0,              // 不含當沖
  "day_trade_realized_pnl": 1800.0,    // 只含當沖
  "total_realized_pnl": 5000.0,        // realized_pnl + day_trade_realized_pnl
  "unrealized_pnl": 12000.0,
  "dividends": {
    "year": 2026,
    "currency": "TWD",
    "total_amount": 3300.0,
    "received_amount": 3000.0,
    "pending_amount": 300.0,
    "total_stock_dividend_shares": 20.0,
    "received_stock_dividend_shares": 0.0,
    "pending_stock_dividend_shares": 20.0,
    "events": [
      {
        "ticker": "2330.TW",
        "ex_date": "2026-06-12",
        "type": "息",
        "status": "received",
        "position_qty": 1000.0,
        "cash_dividend_per_share": 3.0,
        "cash_amount": 3000.0,
        "stock_dividend_ratio": null,
        "stock_dividend_shares": 0.0,
        "source": "TWSE"
      },
      {
        "ticker": "2317.TW",
        "ex_date": "2026-08-01",
        "type": "息",
        "status": "pending",
        "position_qty": 200.0,
        "cash_dividend_per_share": 1.5,
        "cash_amount": 300.0,
        "stock_dividend_ratio": null,
        "stock_dividend_shares": 0.0,
        "source": "TWSE"
      }
    ]
  }
}

dividends 以今年 ex-date 的 dividend_events 與完整歷史 entitlement 計算;年度採 Asia/Taipei 今日所屬年度。現金股利金額預設為 position_qty * cash_dividend,唯一可 歸屬的 payment evidence 可將該事件 override 一次為實際入帳淨額。status="received" 代表 ex_date <= today,status="pending" 代表未來除息/除權日;pending 仍以今日 current holdings 計算。除權配股另以 entitlement position_qty * stock_dividend 估算股數, 不換算進現金 *_amount。


Metrics Naming Specification

所有 portfolio endpoints 的損益欄位命名與語意統一如下。這些規則保證「非重疊加總」—— realized_pnl 與 day_trade_realized_pnl 互斥,相加得到 total_realized_pnl,符合直覺。

欄位 語意 是否含當沖
position_qty 當前持股數(所有 open buy lots 的 remaining_qty 加總) —
avg_buy_price open lots 的 quantity-weighted effective cost per share (含 fee/tax 分攤) —
cost_basis_total open lots 剩餘 qty × effective_cost 的加總 —
unrealized_pnl (market_price - avg_buy_price) × position_qty(當沖結束後不會留下 open lot,因此自然不影響) —
realized_pnl closures 所產生的已實現損益,只含 day_trade_pair_id IS NULL 的 sell leg ❌ 不含
day_trade_realized_pnl closures 所產生的已實現損益,只含 day_trade_pair_id IS NOT NULL 的 sell leg ✅ 只含
total_realized_pnl realized_pnl + day_trade_realized_pnl ✅ 兩者相加

計算細節: - 每筆 buy 產生一個 open lot,effective_cost_per_share = (price × qty + fee + tax) / qty。 - 每筆 sell 透過 transaction_closures 指向一筆或多筆 buy lot,依 closure.qty 扣減 lot 的 remaining_qty。 - 賣出 fee/tax 依 closure.qty / total_sell_qty 比例分攤到每筆 closure。 - short_first 當沖配對允許中間 walk 階段 position_qty 短暫變負(sell leg 比 buy leg 早被處理),最終狀態必定回到非負的淨部位。 - 遇到未知的 action 會 raise ValueError,避免未來 schema 新增 action type 時靜默漂移。


18. 族群 Groups

Prefix: /api/groups

GET /api/groups

列出股票族群。active_count = 族群內有至少一個 is_primary=TRUE 策略匹配的成員數。

Query Params:

名稱 類型 說明
group_type string industry / sector / concept
source_window string 2y(預設)/ all,見 §1 通用說明

GET /api/groups/{group_id}/members

族群成員列表,附帶每個成員的主要策略(is_primary=TRUE)。

Path Params: group_id 可使用 /api/groups 回傳的 canonical group_id(建議),也可使用 exact group_name;若名稱重複,後端會以 group_type, group_id 排序後取第一筆匹配。

Query Params:

名稱 類型 說明
sort string score(預設)/ name
direction string long / short(篩選主策略方向)
source_window string 2y(預設)/ all,見 §1 通用說明
market string OHLC 行情市場,預設 taiwan
trade_date date 行情查詢日期上限;未提供時使用該 market 最新 OHLC 日期
include_price bool 是否回傳 members[].price,預設 true
limit int 回傳筆數(預設 200)
offset int 分頁偏移

Response — member item:

欄位 類型 說明
ticker string 股票代碼
name string 股票名稱
industry string 產業
strategy_key string? 主策略鍵(long_d1, short_d5, ...)
score float? 綜合分數
metrics_2y object? 2y 窗口 8 項指標(hit_rate, expectancy, ...);見 §1
metrics_all object? 全期窗口 8 項指標;見 §1
scope string? global / group / stock / supply_chain / user_group
rule_id string? 規則 ID
trade_date string? 交易日
price object | null 個股行情與漲跌資訊;無最新 close 資料時為 null,include_price=false 時省略

members[].price 欄位:

欄位 類型 說明
as_of_date string | null 實際使用的最新 OHLC 交易日
close number | null as_of_date 收盤價
change number | null 相對前一交易列的價差
change_pct number | null 相對前一交易列的漲跌幅百分比
five_day_close number | null 第 5 個回看交易列收盤價
five_day_change number | null 相對第 5 個回看交易列的價差
five_day_change_pct number | null 相對第 5 個回看交易列的漲跌幅百分比
twenty_day_close number | null 第 20 個回看交易列收盤價
twenty_day_change number | null 相對第 20 個回看交易列的價差
twenty_day_change_pct number | null 相對第 20 個回看交易列的漲跌幅百分比

Response 範例:

{
  "group_id": "semiconductor",
  "group_type": "industry",
  "group_name": "半導體",
  "member_count": 120,
  "found": true,
  "members": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "industry": "半導體",
      "strategy_key": "long_d5",
      "score": 0.82,
      "metrics_2y": {},
      "metrics_all": {},
      "scope": "group",
      "rule_id": "R001",
      "domain": "TA",
      "regime_slice": "all",
      "trade_date": "2026-05-17",
      "price": {
        "as_of_date": "2026-05-17",
        "close": 110.0,
        "change": 10.0,
        "change_pct": 10.0,
        "five_day_close": 90.0,
        "five_day_change": 20.0,
        "five_day_change_pct": 22.2222,
        "twenty_day_close": 80.0,
        "twenty_day_change": 30.0,
        "twenty_day_change_pct": 37.5
      }
    }
  ]
}

成員沒有最新 close 資料時,price 為 null。


GET /api/groups/{group_id}/signals

族群策略訊號 — 回傳 scope='group' 的策略匹配結果。

Path Params: 同 /api/groups/{group_id}/members,可使用 canonical group_id 或 exact group_name。

Query Params:

名稱 類型 說明
strategy_key string 策略鍵篩選(long_d1, short_d5, ...)
direction string long / short
source_window string 2y(預設)/ all,見 §1 通用說明

18B. 統一個股清單 Stock Lists

Prefix: /api/stock-lists

給前端用同一支 API 查「同題材 / 同族群 / 同觀察清單」內的個股與漲跌資訊。這支 API 只回傳 stock 與 price,不包含 legacy endpoint 裡的 industry、sector、策略欄位、watchlist metadata 或 latest_ohlc。

GET /api/stock-lists/items

Query Params:

名稱 類型 預設 說明
searchType string required theme / group / watchlist
searchID string required 查詢 ID;見下方對應表
market string taiwan OHLC 行情市場
tradeDate date latest OHLC 行情查詢日期上限
includePrice bool true 是否回傳 items[].price;false 時 price 固定為 null 且不查 price window
limit int 200 每頁筆數,上限 1000
offset int 0 分頁位移

searchType / searchID 對應:

searchType searchID 來源 認證
theme /api/themes 的 theme_id,對應 public.theme.theme_name 不需要
group /api/groups 的 group_id(建議)或 exact group_name,對應 stock_group.group_id / stock_group.group_name 不需要
watchlist /api/watchlists/groups 的 id,必須是 UUID,對應 watchlist_groups.id 需要 Bearer token,且只能查自己的 group

searchType=theme 使用 public.theme / taiwan.theme_member 的目前成員, 包含手動題材;已封鎖或不存在的題材回傳 found: false、空清單。 有效但尚無成員的題材回傳 found: true、total: 0。不再讀舊 coverage 快照。

Response (200):

{
  "searchType": "theme",
  "searchID": "AI 伺服器",
  "found": true,
  "total": 148,
  "items": [
    {
      "stock": {
        "ticker": "2330.TW",
        "name": "台積電",
        "englishName": "Taiwan Semiconductor"
      },
      "price": {
        "asOfDate": "2026-06-18",
        "close": 2410.0,
        "change": 10.0,
        "changePct": 0.4167,
        "fiveDayClose": 2300.0,
        "fiveDayChange": 110.0,
        "fiveDayChangePct": 4.7826,
        "twentyDayClose": 2230.0,
        "twentyDayChange": 180.0,
        "twentyDayChangePct": 8.0717
      }
    }
  ]
}

Response 欄位:

欄位 類型 說明
searchType string 回傳本次查詢類型
searchID string 回傳本次查詢 ID
found bool source 是否存在;不存在時 total=0、items=[]
total int 分頁前 source 內個股總數
items[].stock.ticker string 股票代碼
items[].stock.name string | null 股票名稱
items[].stock.englishName string | null 英文名稱
items[].price object | null 個股行情與漲跌資訊;沒有 OHLC close 或 includePrice=false 時為 null

items[].price 欄位:

欄位 類型 說明
asOfDate string | null 實際使用的最新 OHLC 交易日
close number | null asOfDate 收盤價
change number | null 相對前一交易列的價差
changePct number | null 相對前一交易列的漲跌幅百分比
fiveDayClose number | null 第 5 個回看交易列收盤價
fiveDayChange number | null 相對第 5 個回看交易列的價差
fiveDayChangePct number | null 相對第 5 個回看交易列的漲跌幅百分比
twentyDayClose number | null 第 20 個回看交易列收盤價
twentyDayChange number | null 相對第 20 個回看交易列的價差
twentyDayChangePct number | null 相對第 20 個回看交易列的漲跌幅百分比

Error / empty behavior:

情境 HTTP Response
searchType 不是 theme/group/watchlist 422 FastAPI validation error
searchType=watchlist 但 searchID 不是 UUID 422 searchID must be a valid UUID when searchType=watchlist
searchType=watchlist 但未帶 Bearer token 401 Auth error
source 不存在或 watchlist group 不屬於目前 member 200 {"found": false, "total": 0, "items": []}

searchType=group 若使用 group_name,後端會先嘗試 exact group_id,再用 exact group_name 解析到 canonical group_id;若名稱在不同分類重複,會以 group_type, group_id 排序後取第一筆。前端仍建議優先使用 /api/groups 回傳的 group_id。 | source 存在但分頁超出範圍 | 200 | {"found": true, "total": <source total>, "items": []} |


19. 策略規則 Rules

Prefix: /api/rules

共用篩選參數

以下參數可用於 catalog、threshold-summary、threshold-rules 三個端點:

名稱 類型 說明
ticker string 篩選 ticker 匹配的規則(不限 scope)
scope string global / group / stock / supply_chain / user_group
group_id string 篩選特定族群
supply_chain_id string 篩選特定供應鏈
commodity_code string 篩選特定商品(domain=COM)
family_id string 篩選特定規則家族(domain=IR)
domain string TA / CT / IR / COM
strategy_key string long_d1, short_d60 等 12 種策略
regime_slice string all / bull / bear / range
min_hit_rate float 最低勝率門檻
min_sample_count int 最低觸發次數
min_ev float 最低期望值(EV = hit_rate × mean_return)
min_pf float 最低獲利因子
include_below_floor bool 是否包含 below_floor 品質規則(預設 true)
source_window string 2y(預設)/ all,見 §1 通用說明

GET /api/rules/catalog/threshold-summary

依門檻篩選後,按 strategy_key 分組統計規則數量。前端 slider 互動用。

額外參數:

名稱 類型 說明
group_by string 額外分組維度(逗號分隔)。允許:domain, scope, regime_slice, quality_tier

Response:

{
  "filters_applied": {"min_hit_rate": 0.6},
  "group_by": ["strategy_key", "domain"],
  "total_rules": 1234,
  "items": [
    {"strategy_key": "long_d1", "domain": "TA", "rule_count": 45, "avg_hit_rate": 0.72, "avg_ev": 0.008, "avg_pf": 2.31}
  ]
}

GET /api/rules/catalog/threshold-rules

依門檻篩選後,回傳通過的規則明細(分頁)。

額外參數:

名稱 類型 預設 說明
limit int 200 每頁筆數(1-1000)
offset int 0 偏移

Response:

{
  "filters_applied": {"min_hit_rate": 0.6},
  "total": 1234,
  "limit": 200,
  "offset": 0,
  "rules": [
    {
      "catalog_key": 123, "rule_id": "G_TA_...", "strategy_key": "long_d1",
      "scope": "global", "domain": "TA",
      "metrics_2y": {"hit_rate": 0.75, "expectancy": 0.012, "sample_count": 28, "profit_factor": 3.2, "...": "..."},
      "metrics_all": {"hit_rate": 0.71, "expectancy": 0.009, "sample_count": 92, "profit_factor": 2.8, "...": "..."},
      "conditions": {}
    }
  ]
}

GET /api/rules/catalog

列出策略規則目錄(支援共用篩選參數 + 分頁)。

額外參數: limit (int, 預設 200), offset (int, 預設 0)

Response:

{
  "filters_applied": {"domain": "TA"},
  "total": 1234,
  "limit": 200,
  "offset": 0,
  "rules": [
    {
      "catalog_key": 123, "rule_id": "G_TA_...", "strategy_key": "long_d1",
      "scope": "global", "domain": "TA",
      "metrics_2y": {"hit_rate": 0.75, "expectancy": 0.012, "sample_count": 28, "profit_factor": 3.2, "...": "..."},
      "metrics_all": {"hit_rate": 0.71, "expectancy": 0.009, "sample_count": 92, "profit_factor": 2.8, "...": "..."},
      "conditions": {}
    }
  ]
}

GET /api/rules/catalog/{catalog_key}

依 catalog_key(PK)取得單一規則詳情。

Response:

{"found": true, "catalog_key": 42, "rule": {"catalog_key": 42, "rule_id": "...", ...}}

GET /api/rules/bucket-summary

策略分組統計摘要(按 strategy_key + domain 分組)。

Query Params:

名稱 類型 預設 說明
include_below_floor bool true 是否包含 below_floor 品質規則
source_window string 2y 2y / all,見 §1 通用說明

20. 個股規則 Stock Rules

Prefix: /api/stock-rules

個股層級「規則探勘」結果的唯讀查詢層。離線流程 scripts/run_stock_rules.py 對單一個股跑 SEARCH → DEFINE → VALIDATE → FINALIZE,把該檔適用的規則帶版本號存進 stock_rule_config, 本章的端點只負責讀出來,不做計算。

config_source 三種:sta(技術面)、scr(籌碼面)、sir。 quality_tier 三種:usable / weak / experimental(判準見 scripts/stock_discovery_common.py::compute_quality_tier)。

本節所有範例取自 live cortex-api(2330.TW,2026-08-20)。

GET /api/stock-rules/{ticker}

取得個股所有啟用中的規則設定(含完整 channels)。

Response — StockRulesResponse:

{
  "ticker": "2330.TW",
  "sources": {
    "sta": {
      "version": 3,
      "computed_at": "2026-03-17T12:38:31.251567+00:00",
      "data_range": { "start": "2018-01-02", "end": "2026-03-17" },
      "sample_count": 1988,
      "quality_tier": "usable",
      "warnings": [],
      "n_rules": 29,
      "channels": {
        "short_term_long": {
          "direction": "long",
          "rules": [
            {
              "id": "S1",
              "name": "adx_lt_20__AND__ma_bullish",
              "conditions": ["adx_lt_20", "ma_bullish"],
              "profit_factor": 3.2376151769127093,
              "primary_horizon": 5,
              "backtest": {
                "1d":  { "hit_rate": 0.601,  "expectancy": 0.002853, "mean_return": 0.002853, "sample_count": 193 },
                "5d":  { "hit_rate": 0.6398, "expectancy": 0.015213, "mean_return": 0.015213, "sample_count": 211 },
                "60d": { "hit_rate": 0.6377, "expectancy": 0.089647, "mean_return": 0.089647, "sample_count": 207 },
                "sample_count": 211,
                "daily_avg_triggers": 0.99,
                "zero_return_excluded": true
              }
            }
          ]
        }
      }
    },
    "scr": { "...": "同形,quality_tier=weak、warnings=[\"small_oos_sample\"]" },
    "sir": { "...": "同形" }
  }
}

backtest 含六個 horizon:1d / 3d / 5d / 10d / 20d / 60d(上例節錄三個)。 查無資料時回 200,sources 為 {}。


GET /api/stock-rules/{ticker}/summary

同上但不含 channels,其餘欄位相同。查無資料時回 200、sources 為 {}。


GET /api/stock-rules/{ticker}/history

取得規則版本歷史。

Query Params:

名稱 類型 預設 說明
source string — 篩選單一 config_source;給值與否會改變回應形狀(見下)
include_channels bool false 是否含 channel 細節

給 source 時回 {ticker, config_source, entries[]}:

{
  "ticker": "2330.TW",
  "config_source": "sta",
  "entries": [
    {
      "version": 3,
      "is_active": true,
      "computed_at": "2026-03-17T12:38:31.251567+00:00",
      "data_range": { "start": "2018-01-02", "end": "2026-03-17" },
      "sample_count": 1988,
      "quality_tier": "usable",
      "n_rules": 29
    }
  ]
}

不給 source 時改回 {ticker, sources{}},sources 的每個 key 是一個 config_source、 值為該來源的 entries 陣列。兩種情況查無資料皆回 200(entries: [] 或 sources: {})。


GET /api/stock-rules/{ticker}/{source}

取得特定來源的啟用中設定。成功時為平鋪的單一物件(/{ticker} 的 sources 內層 再加上 ticker 與 config_source 兩個識別欄):

{
  "ticker": "2330.TW",
  "config_source": "sta",
  "version": 3,
  "computed_at": "2026-03-17T12:38:31.251567+00:00",
  "data_range": { "start": "2018-01-02", "end": "2026-03-17" },
  "sample_count": 1988,
  "quality_tier": "usable",
  "warnings": [],
  "n_rules": 29,
  "channels": { "short_term_long": { "direction": "long", "rules": [] } }
}

查無資料時回 200,識別欄照舊回填、config 本體為空值:

{
  "ticker": "2330.TW",
  "config_source": "sta",
  "version": null,
  "computed_at": null,
  "data_range": { "start": null, "end": null },
  "sample_count": 0,
  "quality_tier": null,
  "warnings": [],
  "n_rules": 0,
  "channels": {}
}

⚠️ 行為變更(issue #1404):此端點過去查無資料時回 404,body 是 {"detail": "No active {source} config for {ticker}"} —— 全庫唯一送出該形狀的端點。 現已統一為 200 + 空 config,與同章其他三條一致(/{ticker}、/{ticker}/summary 回 sources: {},/{ticker}/history?source=X 回 entries: [])。 消費端若以 status === 404 判空,需改判 n_rules === 0 或 version === null。


21. 黑天鵝階段 Black Swan

Prefix: /api/black-swan

GET /api/black-swan/phase

黑天鵝事件階段估算。

Query Params:

名稱 類型 預設 說明
date string — 日期,格式 yyyymmdd
subject string 0050.TW 觀測標的

Response — BlackSwanPhaseResponse:

{
  "date": "20240315",
  "resolved_trading_date": "20240315",
  "mode": "realtime",
  "subject": "0050.TW",
  "proxy_source": null,
  "main_type": "normal",
  "dominant_phase": "recovery",
  "phase_source": "composite",
  "overlap_flag": false,
  "black_swan": {
    "shock": { "pct": 0.1, "matched_rules": ["rule_1"] },
    "panic": { "pct": 0.05, "matched_rules": [] },
    "recovery": { "pct": 0.6, "matched_rules": ["rule_3", "rule_5"] },
    "rebuild": { "pct": 0.25, "matched_rules": ["rule_7"] }
  }
}

GET /api/black-swan/phase-range

黑天鵝事件階段區間查詢 — 回傳日期範圍內每個交易日的階段估算。

Query Params:

名稱 類型 預設 說明
start_date string (YYYY-MM-DD) config default_lookback_days 天前 起始日
end_date string (YYYY-MM-DD) today 結束日
subject string 0050.TW 觀測標的

驗證規則: - start_date 必須 ≤ end_date,否則 422 - 區間不得超過 config max_range_days(預設 180 天),否則 422

Response — BlackSwanPhaseRangeResponse:

{
  "subject": "0050.TW",
  "start_date": "2025-02-18",
  "end_date": "2025-03-20",
  "count": 22,
  "daily": [
    {
      "trading_date": "2025-02-18",
      "dominant_phase": "pre_shock",
      "phase_source": "estimated",
      "overlap_flag": false,
      "warmup": false,
      "black_swan": {
        "pre_shock": { "pct": 0.70, "matched_rules": ["rule_a"] },
        "shock_start": { "pct": 0.10, "matched_rules": [] },
        "panic_active": { "pct": 0.10, "matched_rules": [] },
        "recovery": { "pct": 0.10, "matched_rules": ["rule_b"] }
      }
    }
  ]
}

warmup: true 表示該日的 trailing window 不足完整天數,估算結果可能不夠穩定。


22. 供應鏈分析 Supply Chain

Prefix: /api/v1/supply-chain

供應鏈知識圖譜分析。基於 My-TW-Coverage 1,735 家台股研究報告的 RAG 語義搜尋與結構化圖譜。

POST /api/v1/supply-chain/analyze

🔒 Auth: 需登入(Authorization: Bearer <access_token>)。無效 token 回 401;未帶 Authorization header 回 422。

LLM 供應鏈分析(需等待 AI 回應,約 5-15 秒)。

Request Body:

{
  "ticker": "2330",
  "trading_date": "2026-03-31",
  "response_mode": "json"
}
欄位 型別 必填 說明
ticker string 是 台股代碼
trading_date string 否 交易日期 (YYYY-MM-DD)
response_mode string 否 "json" (預設) 或 "stream"

Response (200):

{
  "agent_name": "supply_chain",
  "ticker": "2330",
  "summary": "供應鏈分析摘要...",
  "signal": "bullish",
  "confidence": 0.72,
  "engine_signal": null,
  "engine_confidence": null,
  "enabled_dimensions": ["coverage_profile", "supply_chain_relations", "entity_mentions"],
  "skipped_dimensions": ["theme_membership"],
  "key_points": [
    { "text": "台積電為 Apple、NVIDIA 核心供應商", "evidence": "rel_type=customer; target=Apple; bidirectional=true" }
  ],
  "risks": [
    { "text": "上游設備依賴 ASML 獨家 EUV 供應", "evidence": "rel_type=upstream; target=ASML; mention_count=5" }
  ],
  "recommendations": [
    { "text": "關注 NVIDIA AI GPU 需求帶動先進製程營收", "evidence": "rel_type=downstream; target=NVIDIA; theme=AI伺服器" }
  ],
  "parser_warnings": []
}

GET /api/v1/supply-chain/profile/{ticker}

取得個股 Coverage 研究報告 Profile(不呼叫 LLM,即時回應)。 ticker 可帶 raw code 或交易所尾碼;response 會優先回傳 stocks.ticker 中的 canonical 代號(direct match → .TW → .TWO)。

Response (200):

{
  "ticker": "2330.TW",
  "company_name": "台積電",
  "sector": "Semiconductors",
  "sector_en": "Technology",
  "industry_en": "Semiconductors",
  "market_cap_m": 47845508,
  "ev_m": 45886629,
  "description": "台積電成立於 1987 年,總部位於台灣新竹..."
}
狀態碼 說明
404 無此 ticker 的 coverage 資料

GET /api/themes

列出 coverage 知識庫中的完整題材清單。資料來源是 coverage_theme_membership,對應 data/my-tw-coverage/themes/*.md 匯入後的主題成員資料。API 只回傳最新 knowledge_as_of 快照。 coverage 原始資料若只有 raw code,member count 會以 canonical ticker 去重: direct match → .TW → .TWO → raw code。

Response (200):

[
  {
    "theme_id": "AI 伺服器",
    "theme_name": "AI 伺服器",
    "member_count": 148,
    "is_eligible": true
  },
  {
    "theme_id": "CoWoS",
    "theme_name": "CoWoS",
    "member_count": 39,
    "is_eligible": true
  }
]

Phase 1 更新(資料來源切換):資料來源已改為新題材正典表 public.theme + taiwan.theme_member,不再讀 taiwan.coverage_theme_membership;response 形狀不變 (欄位仍是 theme_id/theme_name/member_count/is_eligible)。is_blocked=true (已刪除)的題材視同不存在,一律從清單排除。下市股票(stocks.is_active = false) 不計入 member_count 的規則維持不變。


GET /api/themes/{theme_id}/stocks

取得 coverage 題材的成員清單。theme_id 是題材名稱,例如 AI 伺服器、CoWoS、電動車;放在 URL path 時需做 URL encoding。 成員 ticker 會用 stocks 表 canonicalize:先比對 direct ticker,再依序嘗試 .TW、.TWO,仍查不到才回傳 coverage raw code。

Query Params:

名稱 類型 預設 說明
limit int themes.default_limit 每頁筆數,超過 themes.max_limit 時自動截到上限
offset int 0 分頁位移
market string taiwan OHLC 行情市場
trade_date date latest OHLC 行情查詢日期上限
include_price bool true 是否回傳 stocks[].price;false 時省略

Response (200):

{
  "theme_id": "AI 伺服器",
  "theme_name": "AI 伺服器",
  "found": true,
  "member_count": 148,
  "stocks": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "english_name": "TSMC",
      "industry": "半導體業",
      "sector": "資訊科技",
      "security_type": "common",
      "price": {
        "as_of_date": "2026-06-18",
        "close": 144.0,
        "change": -1.5,
        "change_pct": -1.0309,
        "five_day_close": 145.0,
        "five_day_change": -1.0,
        "five_day_change_pct": -0.6897,
        "twenty_day_close": 137.5,
        "twenty_day_change": 6.5,
        "twenty_day_change_pct": 4.7273
      }
    }
  ]
}

題材不存在時回傳 found: false 與空 stocks 陣列,HTTP status 仍為 200。 成員沒有最新 close 資料時,price 為 null。security_type(issue #1282,見 §3.x stocks 端點說明的 15 值枚舉) 於成員可 canonicalize 到 stocks 表時回傳實際分類值;退回 coverage raw code(查無對應 stocks 列)時為 null。

Phase 1 更新(資料來源切換):資料來源已改為新題材正典表 public.theme + taiwan.theme_member,不再讀 taiwan.coverage_theme_membership;response 形狀不變。 is_blocked=true(已刪除)的題材視同不存在,回傳 found: false。下市股票的過濾規則 維持不變(stocks.is_active = false 的成員不列入 stocks 與 member_count)。


POST /api/admin/themes/generate

啟動 admin-only 題材生成 job。這支 API 會用 coverage RAG / web search / LLM 產生題材草稿,不會直接把 LLM 結果寫成正式題材;正式套用需呼叫 publish。 job 由 API process 內的 background task 執行;若 process 重啟,執行中的 job 不會續跑,startup recovery 會把超過 theme_generation.jobs.stale_running_minutes 的 running / pending job 標記為 failed。 web / coverage_rag_then_web 會先建立 job;如果 web search provider 未設定, 錯誤會記錄在 job status 的 error 欄位,而不是讓啟動 API 回 422。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Request Body:

欄位 類型 必填 預設 說明
theme string yes - 題材關鍵字,例如 液冷散熱
market string no taiwan 研究市場
source enum no theme_generation.default_source coverage_rag、web、coverage_rag_then_web
max_members int no theme_generation.default_max_members 候選成員上限
publish bool no false 是否生成完成後自動 publish
rebuild_after_publish bool no config publish 後是否接 rebuild
sync_db_after_rebuild bool no true rebuild 後是否同步 DB

Response (202):

{
  "job_id": "theme-generate-20260521-031500-a1b2c3d4",
  "status": "pending",
  "kind": "generate",
  "theme": "液冷散熱",
  "created_at": "2026-05-21T03:15:00"
}

POST /api/admin/themes/rebuild

啟動 admin-only 題材重建 job。這支 API 不做新研究,只讀取目前既有 coverage / wikilinks / themes/*.md,重建題材並可同步到 coverage_theme_membership。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Request Body:

欄位 類型 必填 預設 說明
theme string/null no null 指定單一題材;null 代表重建全部
sync_db bool no true 是否執行 CoverageETL 同步 DB
knowledge_as_of date/null no server date 同步快照日期
skip_embed bool/null no theme_generation.sync.skip_embed true 時不重算 embeddings

Response (202):

{
  "job_id": "theme-rebuild-20260521-031530-e5f6a7b8",
  "status": "pending",
  "kind": "rebuild",
  "theme": "AI 伺服器",
  "created_at": "2026-05-21T03:15:30"
}

GET /api/admin/themes/jobs/{job_id}

查詢題材生成或重建 job 狀態與結果。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Status: pending、running、completed、failed、published、canceled。

candidates[].confidence 會以 JSON number 或 null 回傳,與 result.members[].confidence 一致。

result.coverage_knowledge_as_of(#774):本次生成所依據的語料世代日期(ISO date)。 僅 coverage 來源(coverage_rag / coverage_rag_then_web)會帶值;web 來源或語料 查詢失敗時為 null。同一日期也會標注於發布後的題材頁 (**語料版本 (knowledge_as_of):** ... 行)與 LLM prompt(語料知識截止日聲明)。

Response (200):

{
  "job_id": "theme-generate-20260521-031500-a1b2c3d4",
  "kind": "generate",
  "status": "completed",
  "theme": "液冷散熱",
  "progress": {"current": 3, "total": 3, "percentage": 100},
  "result": {
    "theme": "液冷散熱",
    "theme_name": "液冷散熱",
    "description": "AI 資料中心高功耗伺服器帶動液冷散熱需求。",
    "coverage_knowledge_as_of": "2026-06-18",
    "members": [
      {
        "ticker": "3017",
        "company_name": "奇鋐",
        "role": "midstream",
        "reason": "伺服器散熱模組供應商。",
        "confidence": 0.91,
        "source": "coverage_rag_then_web",
        "evidence": ["coverage 提到 AI 伺服器散熱模組"]
      }
    ]
  },
  "candidates": [
    {
      "id": 12,
      "theme": "液冷散熱",
      "ticker": "3017",
      "company_name": "奇鋐",
      "role": "midstream",
      "reason": "伺服器散熱模組供應商。",
      "confidence": 0.91,
      "source": "coverage_rag_then_web",
      "evidence": ["coverage 提到 AI 伺服器散熱模組"],
      "suggested_update": {},
      "approved": true,
      "created_at": "2026-05-21T03:15:20"
    }
  ],
  "error": null,
  "created_at": "2026-05-21T03:15:00",
  "started_at": "2026-05-21T03:15:01",
  "completed_at": "2026-05-21T03:15:20",
  "published_at": null
}

未知 job 回傳 404。


PATCH /api/admin/themes/jobs/{job_id}/candidates

批次更新已完成 generate job 的候選股審核狀態。前端可一次送出多筆 candidate_id / approved,讓使用者剔除不相干個股後再 publish。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Request Body:

{
  "updates": [
    {"candidate_id": 12, "approved": false},
    {"candidate_id": 13, "approved": true}
  ]
}

Response (200):

{
  "job_id": "theme-generate-20260521-031500-a1b2c3d4",
  "updated_count": 2,
  "candidates": [
    {
      "id": 12,
      "ticker": "3017",
      "company_name": "奇鋐",
      "role": "midstream",
      "reason": "伺服器散熱模組供應商。",
      "confidence": 0.91,
      "source": "coverage_rag_then_web",
      "evidence": ["coverage 提到 AI 伺服器散熱模組"],
      "approved": false
    }
  ]
}

未知 job 或 candidate 回傳 404;非 completed generate job 回傳 409。


POST /api/admin/themes/jobs/{job_id}/candidates

在 review 階段手動注入 LLM 漏掉的候選股。候選以 source="manual"、 approved=true 寫入,後續 publish 既有流程(讀 approved candidates)會自然 帶入,無需額外操作。發布時該成員行會標記 (人工)。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Request Body:

欄位 類型 必填 預設 說明
ticker string yes — 個股代號(4-6 位數字,其他格式回傳 422)
company_name string yes — 公司名稱(成員行需具名才可被 ETL 解析,Gemini r3)
role string no related upstream / midstream / downstream / related
reason string no null 加入理由

Response (200): candidates 為該 job 的完整候選清單(含剛注入的一筆)。

{
  "job_id": "theme-generate-20260521-031500-a1b2c3d4",
  "candidate": {
    "id": 14,
    "ticker": "6435",
    "company_name": "台勝科",
    "role": "upstream",
    "reason": "漏掉的候選",
    "confidence": null,
    "source": "manual",
    "evidence": [],
    "approved": true
  },
  "candidates": [
    {
      "id": 12,
      "ticker": "3017",
      "company_name": "奇鋐",
      "role": "midstream",
      "confidence": 0.91,
      "source": "coverage_rag_then_web",
      "approved": true
    },
    {
      "id": 14,
      "ticker": "6435",
      "company_name": "台勝科",
      "role": "upstream",
      "confidence": null,
      "source": "manual",
      "approved": true
    }
  ],
  "warnings": []
}

ticker 存在性為軟驗證:若 ticker 不在 stocks 表,候選仍會加入,但 warnings 會帶出 "ticker {t} 不在 stocks 表(仍已加入)" 提示。

未知 job 回傳 404;非 completed generate job、或同 job 同 ticker 已存在回傳 409。


POST /api/admin/themes/jobs/{job_id}/cancel

取消尚未完成的題材生成或重建 job。可取消 pending / running job;若 job 正在背景執行,執行流程會在下一個階段檢查到 canceled 後停止更新結果。 Cancel 是 cooperative:它會標記 job,但正在進行中的 HTTP / LLM / subprocess 呼叫會先完成或 timeout,worker 才會在下一個 checkpoint 退出。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Response (200):

{
  "job_id": "theme-rebuild-20260521-031530-e5f6a7b8",
  "status": "canceled",
  "error": "Cancelled by admin",
  "completed_at": "2026-05-21T03:15:45"
}

未知 job 回傳 404;completed / published job 回傳 409。


POST /api/admin/themes/jobs/{job_id}/publish

將已完成的 generate draft 套用成正式題材資料。publish 只會使用 approved=true 的 candidates;使用者透過批次 candidates API 設為 approved=false 的個股不會加入題材。publish 會把 approved draft 寫成 generated themes/*.md 頁面;當 apply_coverage_updates=true 時,也會把 published theme wikilink 寫入核准成員對應的 Pilot_Reports/**/*.md 報告。 最後再視參數觸發 rebuild / CoverageETL sync。若所有 candidates 都被 reject, publish 回傳 409。

Auth: Bearer token,且會員需為 admin(members.is_admin = true)。

Request Body:

欄位 類型 必填 預設 說明
apply_coverage_updates bool no true 是否更新核准成員 Pilot_Reports 報告內的 published theme wikilink
apply_theme_definition bool no true 保留給 THEME_DEFINITIONS 更新;目前會回傳 warning
rebuild_after_publish bool no true publish 後是否啟動 rebuild job
sync_db_after_rebuild bool no true rebuild 後是否同步 DB

Response (200):

{
  "job_id": "theme-generate-20260521-031500-a1b2c3d4",
  "status": "published",
  "published_at": "2026-05-21T03:16:00",
  "rebuild_job_id": "theme-rebuild-20260521-031600-c9d0e1f2",
  "warnings": [
    "目前尚未自動更新 THEME_DEFINITIONS。"
  ]
}

題材頁成員編輯

以下四個端點把「手動編輯 Obsidian 題材頁」包成 API:題材頁(themes/<theme>.md) 即事實來源,API 做 line-surgery(只動目標成員行,其餘描述、相關主題行、他人 成員行含既有標記逐字保留),人工新增行帶 (人工) 標記,並同步更新各分節 ## X (N) 計數與 **涵蓋公司數:**。

所有編輯端點都會回傳一則 warnings 提示:若題材屬白名單頁,下次 build_themes rebuild 可能覆蓋 (人工) 標記的成員行(build_themes 保留語義的 PR 落地前)。

sync_db=true 時,寫檔成功後會觸發 CoverageETL sync 讓 DB 跟上頁面。寫檔 成功即編輯成功:sync 失敗不會回滾、不回 500,而是以 warnings 帶出並把 sync_report 設為 null(避免 client 誤判編輯失敗而重試造成重複行)。

Auth(四端點皆同): Bearer token,且會員需為 admin(members.is_admin = true)。

GET /api/admin/themes/{theme}/page-members

回傳題材頁解析出的成員(供編輯 UI 顯示頁面實態,與 DB 版成員清單區隔)。

Response (200):

{
  "theme": "功率半導體",
  "members": [
    {"ticker": "1234", "company_name": "甲公司", "role": "upstream", "manual": false},
    {"ticker": "6435", "company_name": "台勝科", "role": "upstream", "manual": true}
  ]
}

題材頁不存在回傳 404。

POST /api/admin/themes/{theme}/members

在指定 role 分節新增一筆成員行 - **{ticker} {company_name}** (人工);無該分節 時依 上游→中游→下游→相關 順序建立新節。

Request Body:

欄位 類型 必填 預設 說明
ticker string yes — 個股代號(4-6 位數字,其他格式回傳 422)
company_name string yes — 公司名稱(成員行需具名才可被 ETL 解析)
role string yes — upstream / midstream / downstream / related
sync_db bool no false 是否於寫檔後同步 DB

ticker 存在性為軟驗證:若 ticker 不在 stocks 表,成員仍會加入,但 warnings 會多帶一則 "ticker {t} 不在 stocks 表(仍已加入)" 提示。

Response (200):

{
  "theme": "功率半導體",
  "members": [
    {"ticker": "1234", "company_name": "甲公司", "role": "upstream", "manual": false},
    {"ticker": "6435", "company_name": "台勝科", "role": "upstream", "manual": true}
  ],
  "warnings": ["此編輯已直接寫入題材頁。若該題材屬白名單頁,下次 build_themes rebuild 可能覆蓋 (人工) 標記的成員行。"],
  "sync_report": null,
  "excluded_for_rebuild": false
}

excluded_for_rebuild(bool,所有編輯端點皆回傳)表示本次編輯是否往頁面 **人工排除:** 行寫入了 ticker。僅 DELETE 衍生成員時可能為 true(見下方 DELETE 說明);POST/PATCH 一律為 false。POST 重新加回先前被排除的 ticker 時, 會把它從 **人工排除:** 行移除(add-beats-exclude),該行清空則整行刪除。

題材頁不存在回傳 404;ticker 已在頁上回傳 409(改用 PATCH 調整 role)。

PATCH /api/admin/themes/{theme}/members/{ticker}

把既有成員行逐字搬移至新的 role 分節(保留其 (sector) / (LLM 生成) / (人工) 等註記),兩側分節計數同步更新,空分節整節移除。

Request Body:

欄位 類型 必填 預設 說明
role string yes — 目標 role:upstream / midstream / downstream / related
sync_db bool no false 是否於寫檔後同步 DB

Response (200): 同 POST .../members 的 ThemePageEditResponse。

{ticker} path 參數僅接受 4-6 位數字,其他格式回傳 422。 題材頁不存在、或 ticker 不在頁上回傳 404。

DELETE /api/admin/themes/{theme}/members/{ticker}

刪除指定成員行,更新計數,空分節整節移除。

刪除衍生成員(無 (人工) 標記,即 build_themes 會在 rebuild 時重新推導出 的成員)時,除了移除該行,還會把 ticker 追加到頁面的 **人工排除:** [[DDDD]] ... 行(無此行則建立;位置緊接 **相關主題:** 行之後, 無相關主題行時緊接 **涵蓋公司數:** 區塊之後),使刪除在下次 build_themes rebuild 後仍生效;此時 excluded_for_rebuild 回 true。刪除手動成員 ((人工) 標記,屬 admin 新增、非衍生)僅移除該行、不寫排除行, excluded_for_rebuild 回 false。

**人工排除:** 行以裸 ticker wikilink 表示,語法對齊語料 build_themes;ETL _parse_themes 顯式跳過該行、不將其 wikilink 當成成員回灌(#840)。

Query Parameters:

參數 類型 必填 預設 說明
sync_db bool no false 是否於寫檔後同步 DB

Response (200): 同 POST .../members 的 ThemePageEditResponse(衍生成員刪除時 excluded_for_rebuild 為 true)。

{ticker} path 參數僅接受 4-6 位數字,其他格式回傳 422。 題材頁不存在、或 ticker 不在頁上回傳 404。


Theme Store(題材正典 CRUD)— /api/admin/theme-store

跨環境發送另提供 /transfers/environments、/transfers/preview、/transfers/send, 以及專用憑證保護的 /api/internal/theme-store/transfers/preview、/apply。 完整 request/response、錯誤、原子覆蓋與重試規則見 題材跨環境發送。既有下列 CRUD 契約不變。

Prefix: /api/admin/theme-store(admin only) Auth(本節八支端點皆同): Bearer token,且會員需為 admin(members.is_admin = true)。

命名注意:既有 RAG 生成工作流是 /api/admin/themes;本節是 /api/admin/theme-store。兩者字面接近但不是同一套系統:themes=coverage RAG 草稿產生/發布工作流(§本章前段);theme-store=題材正典 CRUD (本節),資料表是全新的 public.theme / taiwan.theme_member。

題材正典表:public.theme(題材本身)+ taiwan.theme_member(成員關係), migration 151_theme_store.sql。Schema 細節(含「為什麼沒有 knowledge_as_of」「為什麼 listing_type 存在成員列」)見 docs/DATABASE_SCHEMA.md「Theme store(題材正典)」一節。

共通規則:

  • ticker 沿用既有 THEME_MEMBER_TICKER_PATTERN(^\d{4,6}$);同一次寫入 payload 內 ticker 重複 → 422。
  • theme_name 必須是單一 URL path segment:theme_name 同時是自然鍵與 /themes/{theme_name}(以及公開 GET /api/themes/{theme_id}/stocks)的 path 參數,而這些路由用的是 Starlette 預設 converter(只吃一段 path), server 又會在 routing 前先解碼,因此 AI/server 這種名稱就算建立成功也 永遠無法再被讀取/編輯/刪除。POST /themes 因此拒收:名稱含 /、\、 任何 C0 控制字元(ord(c) < 32),或名稱正好是 . / .. → 422, 不寫入任何資料。字元規則已發佈在 OpenAPI schema 上 (CreateThemeRequest.theme_name.pattern = ^[^/\\\x00-\x1f]+$), 違反者的 detail[].type 是 string_pattern_mismatch、detail[].ctx.pattern 就是該 pattern;保留名稱 . / .. 無法寫進 pattern(pydantic v2 以 Rust regex 編譯,沒有 lookahead),改由伺服器端驗證器擋下,detail[].msg 帶有 題材名稱不可包含路徑分隔字元或控制字元。 POST /import-from-coverage 遇到同樣的名稱則列入 skipped[] (reason = "題材名稱不是合法的路徑片段"),見該端點小節。
  • 任何寫入成員或描述的操作,都會把該題材 is_edited 設為 true (POST /import-from-coverage 判斷是否跳過的依據)。全新建立的手動題材固定 is_edited=false。
  • 代價:coverage 來源題材一旦被編輯過(哪怕只改一個欄位),就永久不再 接收 POST /import-from-coverage 的更新,直到走還原路徑。
  • 還原路徑(不需要新 API):DELETE /themes/{theme_name}(刪除同時把 is_edited 重設為 false)→ DELETE /blocked/{theme_name}(允許重新接入) → 重新呼叫 POST /import-from-coverage,即以 coverage 版本重新接入。
  • PUT /themes/{theme_name}/members 是整批取代:不在 payload 內的既有 成員會被刪除。
  • 手動寫入路徑(POST /themes、PUT .../members)的 listing_type:從 taiwan.stocks 查詢帶入;查無時不會存 null——payload 內任何一個 ticker 解析不出上市/上櫃類別(不在 taiwan.stocks,也沒有可用的歷史掛牌 證據),整個請求回 422,detail 為 "無法判定上市/上櫃(不在股票主檔): <codes>"(<codes> 為逗號分隔的 無法解析代號),不寫入任何資料。taiwan.theme_member.listing_type 是 NOT NULL(見 docs/DATABASE_SCHEMA.md),這是唯一能滿足這條約束的寫入 規則。POST /import-from-coverage 的解析規則不同,另見該端點小節。
  • DELETE /themes/{theme_name} 不碰任何檔案、不碰 my-tw-coverage repo,只操作 public.theme / taiwan.theme_member 這兩張表。
  • 鎖:GET /themes/{name}/members 是無鎖的 admin 讀取;POST /themes、 PATCH /themes/{name}、PUT .../members、DELETE /themes/{name}、 DELETE /blocked/{name} 與 POST /import-from-coverage wrapper 皆會鎖該題材列 (SELECT ... FOR UPDATE,dry_run=true 除外)。
  • 順序:POST /themes 先檢查同名題材是否已存在(且未刪除),才解析 payload 內的 ticker;因此即使 payload 內同時帶有無法解析的 ticker,同名已存在 仍回 409,不會被 ticker 解析的 422 蓋過去。PUT .../members 先檢查題材 存在性/可見性,才解析 payload 內的 ticker;因此即使 payload 內同時帶有無法 解析的 ticker,題材不存在(或已墓碑化)仍回 404,不會被 ticker 解析的 422 蓋過去。

共用 JSON 型別

ThemeMemberIn(寫入用,POST /themes、PUT .../members 的 members[]):

欄位 型別 必填 說明
ticker string 是 4-6 位數字裸代號(THEME_MEMBER_TICKER_PATTERN),格式錯誤回 422
company_name string 是 至少 1 字元,max_length=200,超過回 422
role string 否 upstream / midstream / downstream / related 之一

ThemeMemberOut(讀取用):

欄位 型別 說明
ticker string 裸代號
company_name string | null
listing_type string tw / two;NOT NULL(寫入時解析不出即拒絕寫入或跳過該成員,見下方各端點)
role string | null
source string manual / coverage

ThemeSummary:

欄位 型別 說明
theme_name string 自然鍵,等同既有 GET /api/themes 的 theme_id
description string | null
source string manual / coverage
is_edited boolean 是否被人工編輯過
member_count integer
created_at datetime
updated_at datetime

ThemeDetail = ThemeSummary + members: ThemeMemberOut[]

ticker(API 欄位)= DB 的 stock_code(裸 4-6 位數字代號),全文一律用 ticker 稱呼。

is_blocked 可見性規則

墓碑列(is_blocked=true,即已刪除的題材)佔用著 theme_name 這個自然鍵, 以下規則對本節所有端點一體適用:

對象 對 is_blocked=true 題材的行為
GET /themes(清單)、GET /api/themes、GET /api/themes/{id}/stocks 視同不存在(從清單/回應中過濾掉;後者回 found: false)
GET /themes/{name}/members 視同不存在 → 404(讓匯入 dry-run 能正確判定成「建立」)
PUT /themes/{name}/members、PATCH /themes/{name} 視同不存在 → 404(不會寫進墓碑列,避免「內容寫回去了但 is_blocked 仍是 true、wrapper 永遠跳過」的矛盾狀態)
POST /themes(建立) 不回 409。偵測到目標名稱是墓碑列時=以全新手動題材重用這個名稱:is_blocked=false、source="manual"、is_edited=false,描述與成員一律吃新 payload(不保留任何舊資料)
DELETE /themes/{name}(刪除本身) 清成員 + is_blocked=true + is_edited=false(完整狀態重置)。目標本身已是墓碑列 → 視同不存在 → 404(連點兩次刪除、雙分頁同時操作都得到一致結果)
DELETE /blocked/{name} 唯一看得到墓碑的端點,語意=讓 coverage 版本可以重新被 wrapper 接入(is_blocked=false,其餘欄位不動)。僅適用於 source="coverage" 的墓碑;對 source="manual" 的墓碑回 400,detail 指向 POST /api/admin/theme-store/themes
POST /import-from-coverage(wrapper) 跳過並列入 skipped[],reason = "已刪除,不再接入"

兩種意圖各有一條清楚的路徑,不共用同一個端點硬凹:

  • 「我要拿回這個名字,做一個全新無關的題材」→ 直接 POST /themes
  • 「我要讓 coverage 那版回來」→ DELETE /blocked/{name} → 再跑一次 POST /import-from-coverage

GET /api/admin/theme-store/themes

列出題材正典表中的所有題材,依 theme_name 排序。

Response (200):

{
  "themes": [
    {
      "theme_name": "CoWoS",
      "description": null,
      "source": "coverage",
      "is_edited": false,
      "member_count": 39,
      "created_at": "2026-09-02T03:00:00Z",
      "updated_at": "2026-09-02T03:00:00Z"
    }
  ]
}

無題材時回傳 {"themes": []},HTTP 200(例如上線初期尚未跑過 POST /import-from-coverage)。is_blocked=true 的題材見上方「is_blocked 可見性規則」,一律排除。


POST /api/admin/theme-store/themes

建立一個全新的手動題材。

Request Body:

欄位 型別 必填 說明
theme_name string 是 題材名稱(自然鍵),至少 1 字元,max_length=200,前後空白會被 strip;必須是單一 URL path segment(不可含 /、\、控制字元,也不可是 . / ..),見上方「共通規則」
description string 否
members ThemeMemberIn[] 是 至少 1 筆

Response (201): ThemeDetail

{
  "theme_name": "手動題材A",
  "description": "測試用途",
  "source": "manual",
  "is_edited": false,
  "member_count": 1,
  "created_at": "2026-09-02T03:00:00Z",
  "updated_at": "2026-09-02T03:00:00Z",
  "members": [
    {
      "ticker": "3037",
      "company_name": "欣興",
      "listing_type": "tw",
      "role": "upstream",
      "source": "manual"
    }
  ]
}
狀態碼 說明
201 建立成功
409 同名題材已存在且未被刪除;正常情況下由 SELECT ... FOR UPDATE 序列化,此 409 是 unique constraint race 的 defence-in-depth 安全網,實務上不應觸發(已被轉譯為 409,不會外洩成 500)
422 驗證失敗(theme_name 空字串或超過 200 字、members 為空、ticker 格式錯誤、payload 內 ticker 重複、company_name 超過 200 字等);theme_name 不是單一 path segment(含 /、\、控制字元,或正好是 . / ..),detail[].msg 帶有 "題材名稱不可包含路徑分隔字元或控制字元";或 members 內任一 ticker 無法判定上市/上櫃,detail 為 "無法判定上市/上櫃(不在股票主檔): <codes>"。以上任一情況都不寫入任何資料

新建立的手動題材固定 source="manual"、is_edited=false;成員一律以 source="manual" 寫入,listing_type 解析見上方「共通規則」。目標名稱是 墓碑列時的特殊行為見上方「is_blocked 可見性規則」。


PATCH /api/admin/theme-store/themes/{theme_name}

只改描述,不改名、不動成員。

Request Body:

欄位 型別 必填 說明
description string | null 是 新描述;null 清空

Response (200): ThemeDetail(本次呼叫會把 is_edited 設為 true)

{
  "theme_name": "CoWoS",
  "description": "先進封裝供應鏈",
  "source": "coverage",
  "is_edited": true,
  "member_count": 39,
  "created_at": "2026-09-02T03:00:00Z",
  "updated_at": "2026-09-02T03:05:00Z",
  "members": [
    {
      "ticker": "3037",
      "company_name": "欣興",
      "listing_type": "tw",
      "role": "upstream",
      "source": "coverage"
    }
  ]
}
狀態碼 說明
200 成功
404 題材不存在,或 is_blocked=true(見上方「is_blocked 可見性規則」)

若目標題材原本是未編輯的 coverage 題材,本次呼叫後即永久跳出 POST /import-from-coverage 的更新範圍,見上方「共通規則」的代價揭露。


DELETE /api/admin/theme-store/themes/{theme_name}

刪除題材:清空所有成員、把該題材標記為「不再接入」(is_blocked=true)。 不碰任何檔案、不碰 my-tw-coverage repo,只操作 public.theme / taiwan.theme_member 這兩張表。

Response: 無 body(204)。寫入結果:is_blocked=true、 is_edited=false(完整狀態重置),source 維持原值不變。

狀態碼 說明
204 刪除成功
404 題材不存在,或已是墓碑列(視同不存在,見上方「is_blocked 可見性規則」;連續呼叫兩次得到一致的 404)

還原路徑見上方「is_blocked 可見性規則」與「共通規則」。


DELETE /api/admin/theme-store/blocked/{theme_name}

取消「不再接入」標記,讓該題材可以被下一次 POST /import-from-coverage 重新接入 coverage 版本。唯一看得到墓碑列的端點。

Response: 無 body(204)。只把 is_blocked 改回 false,其餘欄位不動 ——但因刪除當下已清空成員且 is_edited 已重置為 false,unblock 後的題材是 「存在但成員為空」的狀態,下一次 coverage 同步或 POST /import-from-coverage 會補回仍存在於來源中的成員。

狀態碼 說明
204 成功
404 該題材不是墓碑列(不存在,或存在但 is_blocked=false)
400 該墓碑列 source="manual"(手動題材沒有 coverage 版本可以「重新接入」);detail 指向 POST /api/admin/theme-store/themes,請改用該端點以同名重建

GET /api/admin/theme-store/themes/{theme_name}/members

取得題材目前的成員清單(管理視圖)。不套用下市過濾——已下市成員照樣 列出,管理員才看得到並可移除;公開的 GET /api/themes/{theme_id}/stocks 才會過濾下市股票。

Response (200):

{
  "theme_name": "CoWoS",
  "members": [
    {
      "ticker": "3037",
      "company_name": "欣興",
      "listing_type": "tw",
      "role": "upstream",
      "source": "coverage"
    }
  ]
}
狀態碼 說明
200 成功(無成員時 members: [])
404 題材不存在,或 is_blocked=true(見上方「is_blocked 可見性規則」;此規則讓匯入 dry-run 能以 404 正確判定「這是一個新建立」)

PUT /api/admin/theme-store/themes/{theme_name}/members

整批取代該題材的成員(匯入與批次編輯走這支):不在 payload 內的既有 成員會被刪除。

Request Body:

欄位 型別 必填 說明
members ThemeMemberIn[] 是 至少 1 筆;取代後的完整成員清單

Response (200):

{
  "theme_name": "CoWoS",
  "members": [
    {
      "ticker": "3037",
      "company_name": "欣興",
      "listing_type": "tw",
      "role": "upstream",
      "source": "manual"
    }
  ]
}
狀態碼 說明
200 成功;該題材 is_edited 設為 true
404 題材不存在,或 is_blocked=true(見上方「is_blocked 可見性規則」)
409 並發整批取代撞上 unique-key race(ThemeConflictError)的 defence-in-depth 安全網,實務上不應觸發

並發語意:last-write-wins(不偵測 lost update)。 FOR UPDATE 只序列化寫入、不比對版本: 兩個管理員從同一份成員清單出發時,第二個 PUT 會等第一個提交完再把它整批覆蓋掉,兩邊都得到 200,先寫入者不會收到任何提示。上面的 409 只涵蓋 unique-key race,不是版本衝突偵測。若要避免 互相覆蓋,UI 應在編輯前重新載入成員,或另行導入樂觀鎖(If-Match / 版本欄位)——本期未實作。 | 422 | 驗證失敗(members 為空、ticker 格式錯誤、payload 內 ticker 重複、company_name 超過 200 字等);或 members 內任一 ticker 無法判定上市/上櫃,detail 為 "無法判定上市/上櫃(不在股票主檔): <codes>",此時不寫入任何資料 |

寫入後每筆成員 source="manual";listing_type 解析規則同 POST /themes (見上方「共通規則」)。


POST /api/admin/theme-store/import-from-coverage

wrapper:解析 my-tw-coverage 產出的 themes/*.md,把符合條件的題材 upsert 進題材正典表。這是管理員手動接入題材頁的入口;不寫回任何檔案、 不碰 my-tw-coverage repo、不碰 taiwan.coverage_theme_membership。

Request Body:

欄位 型別 必填 預設 說明
themes string[] 否 (全部) 只處理清單內的題材名稱;不給則處理 coverage 端全部題材頁
dry_run boolean 否 false true 時只回報計畫,不寫入任何資料

逐題材判斷順序(同一 transaction 內,已存在的題材列以 SELECT ... FOR UPDATE 鎖列後才判斷):

  1. 題材名稱不是單一 URL path segment(含 /、\、控制字元,或正好是 . / ..)→ 跳過,skipped[].reason = "題材名稱不是合法的路徑片段"。 檔名本身不可能含 /,但 ..md 的 stem 是 .;這種名稱一旦建立就再也 無法被任何路由讀取/編輯/刪除,因此 fail closed,不建立
  2. is_blocked=true(曾被刪除)→ 跳過,skipped[].reason = "已刪除,不再接入"
  3. 新表沒有這個題材 → 建立,source="coverage"、is_edited=false,成員 source="coverage",角色取自題材頁
  4. 已存在且(source="manual" 或 is_edited=true)→ 跳過, skipped[].reason = "手動維護中"
  5. 已存在且是未編輯的 coverage 題材 → 整批取代成員(角色取自題材頁), updated_at 更新

完全相同的重跑是 no-op(冪等):第 5 點在寫入前會先比對已存的成員與題材頁 解析出的成員(比對 (stock_code, company_name, listing_type, role) 集合;本路徑 source 恆為 coverage)。兩者相同時不發出任何 DELETE/INSERT/UPDATE,也不會 動 updated_at——回應遺失後的重試是同一份邏輯輸入,不該讓對外可見的時間戳 往前跳。該題材仍算 imported(跑完就是同步的)。內容真的變了才會整批取代並 更新 updated_at。

themes 有給值時,清單內若有名稱在 coverage 端找不到對應題材頁 → skipped[].reason = "找不到題材頁"。themes 內的名稱會先去重再排序後才 逐一處理(imported/skipped 也依此順序):每個被處理的題材列的 FOR UPDATE 鎖會持有到最後一次 commit,若照呼叫端給的順序走,["A","B"] 與 ["B","A"] 兩個並發匯入會互相鎖死(PostgreSQL 會以 SQLSTATE 40P01 中止其中一個,那不是 IntegrityError,會外洩成 500)。排序後兩邊取鎖順序一致,環就不存在。

題材頁存在,但頁面內容解析不出任何合法成員行時 → skipped[].reason = "題材頁沒有合法成員"(對一個解析得出、但沒有成員的題材頁 而言,這是唯一會落入 skipped 的理由;讀不到的題材頁不會落到這裡,見下方 503)。

同一題材頁內同一 ticker 出現多次(例如成員列與內文 wikilink 都提到同一支 股票)時,以帶有公司名稱/角色的那一次出現為準;純 wikilink 提及不會 覆蓋既有的成員列資料。

available = 本次解析到的題材頁數,即頁面間相異題材名稱的個數(已排除 README)。恆等式 len(imported) + len(skipped) == available 僅在 themes 未給值時成立;有給 themes 篩選時,available 仍計入所有相異題材頁數, 但 imported/skipped 只涵蓋 themes 內指名的題材,此時恆等式不成立。

dry_run=true 時執行每一個判斷步驟(含 listing_type 解析),但完全不發出 任何 INSERT/UPDATE/DELETE,也不取任何 row lock(第 2 點的 SELECT ... FOR UPDATE 只在 dry_run=false 時執行);imported 回報「本次若 正式執行會被匯入」的題材清單。正式執行(dry_run=false)是單一 transaction, 所有題材處理完後一次性 commit;每個被處理的題材,其 FOR UPDATE 鎖列會 持有到這次 commit 為止,不是逐題材分開提交、也不會中途 rollback 已完成的寫入。

listing_type 解析(僅本端點;手動路徑見上方「共通規則」):題材頁解析 不出 listing_type,對每個成員代號依序嘗試:

  1. 先查 taiwan.stocks.listing_type(一次查詢,涵蓋大多數情況)
  2. 查無時,才在 begin_nested() savepoint 內以 try/except 呼叫 resolve_durable_listing_types 解析歷史掛牌類別;savepoint 讓該呼叫失敗 (例如非 PostgreSQL 環境)時不會拖垮整個 transaction
  3. 仍解不出來 → 該成員被跳過,不寫入(taiwan.theme_member.listing_type 是 NOT NULL,不能存 null),並在 warnings 加入一則 "listing_type unresolved for <code> — member skipped from <theme>" (dedupe 以「題材+代號」為單位,同一代號在多個題材頁中出現且皆 unresolved 時,每個受影響的題材各自產生一則 warning);不會讓整批 匯入中斷或 raise。若一個題材頁因此被跳到只剩 0 個可解析成員,該題材本身 落入 skipped[],reason = "題材頁沒有合法成員"

Response (200):

{
  "imported": ["CoWoS", "AI 伺服器"],
  "skipped": [
    { "theme": "手動題材A", "reason": "手動維護中" },
    { "theme": "已下架題材", "reason": "已刪除,不再接入" }
  ],
  "available": 41,
  "warnings": ["listing_type unresolved for 9999 — member skipped from CoWoS"],
  "dry_run": false
}
狀態碼 說明
200 成功(含 dry_run=true)
409 兩個並發匯入請求競爭建立同一個新題材時,其中一個回 409;同 POST /themes,屬 unique-key race 的 defence-in-depth 安全網,實務上不應觸發
503 coverage 題材頁目錄不存在/無法讀取,或任何一個題材頁檔案讀不到/不是合法 UTF-8(CoveragePagesUnavailableError);detail 固定為 "coverage 題材頁目前無法讀取,請確認 my-tw-coverage 資料目錄已掛載",不會外洩出問題檔案的 server-side 路徑(路徑只寫進 WARNING log)
422 驗證失敗(例如 themes 內元素型別錯誤)

為什麼讀不到的題材頁是 503 而不是「沒有合法成員」:etl.coverage_etl._parse_themes 對單一檔案的 OSError / UnicodeDecodeError 是 continue 略過,因此一個解不開的 題材頁會表現成「有題材名稱、零個成員」——與真的沒有成員的題材頁無從分辨,而且 對一個已存在的 coverage 題材而言,整批取代會把上游其實還在的成員整批刪掉。本端點 因此先自行把每個題材頁的 bytes 讀出來(讀不到就整批 503),再對這份 bytes 的 私有快照做解析,讓驗證與解析看到完全相同的內容。 殘留限制:題材頁的寫入端(build_themes.py、題材頁編輯器)用的是會截斷的 Path.write_text 且不與本端點共用鎖,因此「正好在讀的當下被改寫」仍可能讀到半份 內容,追蹤於 rdaoshiken/Cortex#1441。

Phase 1 限制

  • Web 題材分析已接入正典表 public.theme + taiwan.theme_member: /api/theme-recaps 的清單、詳情、搜尋、成員、訊號與資金流,以及 /api/stock-lists/items?searchType=theme 都使用目前成員,包含手動題材。 is_blocked=true 的題材不會從舊 coverage 資料復活。 題材或成員變更會更新分析快取鍵,同筆數替換成員也會生效。
  • coverage_graph_service、獨立的 chip_recap_service 與 supply_chain 仍使用 coverage 資料,本次 Web 題材分析串接未遷移這些獨立功能。
  • 正典表不會自動清理:coverage 端的題材頁若被刪除,正典表內對應的題材 不會被連動移除或清空,會保留最後一次接入的成員原封不動;要移除只能明確 呼叫 DELETE /themes/{name}。
  • 空題材仍存在:所有未封鎖的題材都可列出與搜尋,即使成員為零。 GET /api/themes/{id}/stocks 與 recap detail 回 found: true、 member_count: 0;stock-lists/items 回 found: true、total: 0。
  • theme_name 是自然鍵,Phase 1不支援題材改名。
  • POST /import-from-coverage 只能手動觸發,沒有排程; taiwan.coverage_theme_membership 本身不受本節 API 影響,仍由既有 CoverageETL 餵資料。
  • 部署回填與持續同步:migration 156_theme_store_coverage_sync.sql 會以 最新 taiwan.coverage_theme_membership 快照回填正典表;之後 CoverageETL 與 ThemeMembershipSync 都在原本交易內同步題材。source=manual、 is_edited=true、is_blocked=true 的題材不會被覆寫。相同內容重跑不改動 題材或成員時間戳;同步失敗時 coverage 與正典寫入一併 rollback。 有任何無法解析成員身份的題材整筆保留現況並發出警告;coverage 缺席不代表刪除。 同步排在 embedding 階段之後,避免題材列鎖橫跨 embedding 供應商的網路呼叫。
  • 只鏡射最新快照:同步接受呼叫端寫入的 knowledge_as_of。回填歷史快照 (例如 scripts/sync_theme_memberships.py --knowledge-as-of <舊日期>)時, 該日期不是最新快照,同步直接返回且不鎖任何題材列,也不改動正典表—— 正典分析反映的一律是最新快照。
  • 跳過的題材會被計數:同步回傳 synced_count 與 skipped_count,兩者 出現在 ETLReport(theme_store_synced / theme_store_skipped)、應用層 log,以及 admin 的 sync_report(publish job 與 sync_db=true 的頁面編輯 都回傳它)。被跳過的題材在所有題材分析端點都不存在,所以不能只留在資料庫 server log,也不能讓呼叫端誤以為同步完全成功。
  • 題材名稱規則兩端一致:正典表的兩個寫入者——admin API 與自動同步——共用 同一組規則(public.is_route_safe_theme_name 與 services.theme_store_service.is_route_safe_theme_name):長度 1–200、不可 含 /、\、C0/DELETE/C1 控制字元,且不可是 . / ..。
  • 快取版本:部署 reader 前需套用 migration 155_theme_store_response_cache_versions.sql;以資料庫版本計數器取代 讀取整份成員清單計算 hash。只修改成員 role 或管理用欄位不使分析快取失效。 migration 146 的 theme_recap.coverage_theme_membership 來源與其 trigger 刻意保留:deploy-sit.yml 會在後續 migration/堆疊重建/健康檢查失敗時 還原前一版 API image,但不會回退已完成的 migration,而舊版 API 仍要求該列 存在(缺少時 theme_recap_full_versions() 直接 RuntimeError)。等舊 reader 不再是 rollback 目標後,再用後續 migration 一併退役列與 trigger。

GET /api/theme-recaps/latest

取得 Theme Recap 頁面的初載 read model。題材與成員來自目前正典表 public.theme + taiwan.theme_member,包含手動與已接入的 coverage 題材, 並排除已封鎖題材。成員以裸 stock_code join 策略、籌碼與技術事件; 對外股票代號依成員保存的 listing_type 轉為 .TW 或 .TWO。 knowledge_as_of 保留舊 coverage 快照日期作相容性 metadata,可為 null; 它不再決定正典題材是否可分析。成員與分析資料是目前狀態,並非該快照的歷史成員。 卡片先依有訊號的成員數、平均策略分數與題材名稱排序,再套用 limit(上限 100)。 active_member_count 代表成員股在來源交易日有 primary/recommended strategy、 籌碼事件或技術事件任一訊號;signal_distribution 以成員股層級彙總這些當日訊號的 dominant bullish/neutral/bearish 方向。avg_win_rate 與 expected_return 仍來自 strategy_match 的策略績效欄位;策略資料缺漏時可為 null。

這支 API 不使用 daily_technical_group_summary.group_id 或 daily_chip_group_flow.group_id 當主要來源,因為題材不等於 stock group aggregation。

Query Params:

名稱 類型 預設 說明
market string taiwan 市場範圍
trade_date date latest available 要求的 recap 日期;無資料時 fallback 到最近可用資料日
selected_theme string top ranked theme 選中題材名稱,URL query 需 encode
limit int 20 回傳 theme card 數量,上限 100
window_days int 30 趨勢線 lookback window,上限 120

Response (200):

{
  "market": "taiwan",
  "knowledge_as_of": "2026-05-10",
  "requested_trade_date": "2026-05-18",
  "source_trade_date": "2026-05-17",
  "is_fallback": true,
  "themes": [
    {
      "theme_id": "AI 伺服器",
      "theme_name": "AI 伺服器",
      "member_count": 42,
      "active_member_count": 18,
      "active_ratio": 0.4286,
      "recap_score": 61.25,
      "technical_score": 64.0,
      "chip_score": 55.0,
      "sparkline": [
        {"date": "2026-05-15", "value": 58.0},
        {"date": "2026-05-17", "value": 61.0}
      ]
    }
  ],
  "selected_theme": {
    "theme_id": "AI 伺服器",
    "theme_name": "AI 伺服器",
    "found": true,
    "member_count": 42,
    "active_member_count": 18,
    "summary": {
      "active_ratio": 0.4286,
      "active_day_change": 0.0312,
      "avg_win_rate": 0.62,
      "expected_return": 0.018,
      "recap_score": 61.25
    },
    "chip": {
      "foreign_net_value": 1280000000.0,
      "trust_net_value": 360000000.0,
      "dealer_net_value": -120000000.0,
      "margin": {
        "as_of_date": "2026-05-17",
        "covered_member_count": 38,
        "margin_balance": 320000,
        "margin_previous": 315000,
        "margin_change": 5000,
        "margin_change_pct": 1.59,
        "margin_buy": 18000,
        "margin_sell": 13000,
        "short_balance": 12500,
        "short_previous": 11000,
        "short_change": 1500,
        "short_change_pct": 13.64,
        "short_buy": 900,
        "short_sell": 2400
      },
      "event_count": 12,
      "chip_score": 55.0,
      "top_net_buy_stocks": [
        {"ticker": "2382.TW", "name": "廣達", "net_value": 520000000.0, "event_score": 91.0}
      ]
    },
    "technical": {
      "technical_score": 64.0,
      "pct_above_sma_20": 57.14,
      "rsi_neutral_ratio": 0.62,
      "macd_bullish_ratio": 0.48,
      "breakout_count": 9,
      "breakdown_count": 3,
      "trend_series": [],
      "badges": ["multi_bullish", "technical_strength", "high_activity"]
    },
    "signal_distribution": {"bullish": 11, "neutral": 5, "bearish": 2},
    "member_ranking": [
      {
        "ticker": "2330.TW",
        "name": "台積電",
        "industry": "半導體",
        "sector": "科技",
        "strategy_key": "long_d5",
        "score": 85.0,
        "win_rate": 0.68,
        "expected_return": 0.0235,
        "technical_score": 86.0,
        "chip_score": 82.0,
        "price": {
          "as_of_date": "2026-05-17",
          "close": 110.0,
          "change": 10.0,
          "change_pct": 10.0,
          "five_day_close": 90.0,
          "five_day_change": 20.0,
          "five_day_change_pct": 22.22,
          "twenty_day_close": 80.0,
          "twenty_day_change": 30.0,
          "twenty_day_change_pct": 37.5
        },
        "margin": {
          "as_of_date": "2026-05-17",
          "margin_balance": 1000,
          "margin_previous": 900,
          "margin_change": 100,
          "margin_change_pct": 11.11,
          "margin_buy": 200,
          "margin_sell": 100,
          "short_balance": 50,
          "short_previous": 25,
          "short_change": 25,
          "short_change_pct": 100.0,
          "short_buy": 10,
          "short_sell": 35,
          "source_market": "TWSE"
        },
        "is_favorite": false
      }
    ],
    "latest_signals": []
  }
}

active_day_change 是本次 active_ratio 減前一個可用 recap 交易日的 active_ratio。foreign_net_value、trust_net_value、dealer_net_value 來自成員股籌碼事件 payload 的法人買賣超金額加總;來源資料缺漏時仍可能為 null。pct_above_sma_20 是 0-100 的百分比;rsi_neutral_ratio 與 macd_bullish_ratio 是 0-1 比例。member_ranking[].price 使用 stock_ohlc 取 source_trade_date 之前最近交易列、前一交易列、第 5 個回看 交易列、第 20 個回看交易列計算收盤價、價差與百分比漲跌幅。chip.margin 與 member_ranking[].margin 使用 margin_balance 取每檔成員股 trade_date <= source_trade_date 的最新一筆資料;題材層的 margin_change_pct 與 short_change_pct 以合計 change 除以合計 previous balance 計算。若沒有 融資融券資料、previous balance 缺漏或為 0,對應欄位回傳 null。

chip.margin 欄位說明:

欄位 型別 說明
as_of_date string | null 題材成員使用的最新融資融券資料日
covered_member_count int 有融資融券資料的成員股數
margin_balance number | null 題材成員融資餘額合計
member_margin_balance_sum number | null 題材成員融資餘額合計;與 margin_balance alias 相同
member_margin_previous_sum number | null 題材成員前一交易日融資餘額合計;與 margin_previous alias 相同
margin_scope string 固定為 theme_members_aggregate,表示成分股聚合,不是官方大盤融資餘額
is_official_market_margin_balance boolean 固定為 false
margin_previous number | null 題材成員前一交易日融資餘額合計
margin_change number | null margin_balance - margin_previous
margin_change_pct number | null 融資餘額合計變動百分比;合計 previous 為 0 或缺漏時為 null
margin_buy number | null 題材成員融資買進合計
margin_sell number | null 題材成員融資賣出合計
short_balance number | null 題材成員融券餘額合計
short_previous number | null 題材成員前一交易日融券餘額合計
short_change number | null short_balance - short_previous
short_change_pct number | null 融券餘額合計變動百分比;合計 previous 為 0 或缺漏時為 null
short_buy number | null 題材成員融券買進合計
short_sell number | null 題材成員融券賣出合計

GET /api/theme-recaps/{theme_id}

取得單一正典題材的 recap detail(包含手動題材)。可見但尚無成員的題材仍回 found: true、member_count: 0;不存在或已封鎖才回 found: false。theme_id 是題材名稱,例如 AI 伺服器、CoWoS、電動車;放在 URL path 時需做 URL encoding。

Query Params:

名稱 類型 預設 說明
market string taiwan 市場範圍
trade_date date latest available 要求的 recap 日期
window_days int 30 趨勢線 lookback window
member_limit int 5 member_ranking preview 數量
signal_limit int 3 latest_signals preview 數量

題材不存在時回傳 found: false、空排行與空訊號陣列,HTTP status 仍為 200。

GET /api/theme-recaps/search

搜尋正典題材或其成員股票,包含手動題材與尚無成員的可見題材,並排除已封鎖題材。

Query Params:

名稱 類型 預設 說明
q string 必填 Theme name、ticker 或公司名稱片段
market string taiwan 市場範圍
limit int 10 結果數量,上限 50

Response (200):

[
  {"kind": "theme", "theme_id": "AI 伺服器", "ticker": null, "name": "AI 伺服器", "member_count": 42},
  {"kind": "stock", "theme_id": "AI 伺服器", "ticker": "2382.TW", "name": "廣達", "member_count": null}
]

GET /api/theme-recaps/{theme_id}/members

取得 Theme Recap 的成員排行 tab。

Query Params:

名稱 類型 預設 說明
market string taiwan 市場範圍
trade_date date latest available 要求的 recap 日期
sort string composite_score composite_score / technical_score / chip_score / win_rate / ticker
limit int 50 Page size,上限 200
offset int 0 Pagination offset

Response (200):

{
  "theme_id": "AI 伺服器",
  "total": 42,
  "limit": 50,
  "offset": 0,
  "requested_trade_date": "2026-05-18",
  "source_trade_date": "2026-05-17",
  "is_fallback": true,
  "members": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "industry": "半導體",
      "sector": "科技",
      "strategy_key": "long_d5",
      "score": 85.0,
      "win_rate": 0.68,
      "expected_return": 0.0235,
      "technical_score": 86.0,
      "chip_score": 82.0,
      "price": {
        "as_of_date": "2026-05-17",
        "close": 110.0,
        "change": 10.0,
        "change_pct": 10.0,
        "five_day_close": 90.0,
        "five_day_change": 20.0,
        "five_day_change_pct": 22.22,
        "twenty_day_close": 80.0,
        "twenty_day_change": 30.0,
        "twenty_day_change_pct": 37.5
      },
      "margin": {
        "as_of_date": "2026-05-17",
        "margin_balance": 1000,
        "margin_previous": 900,
        "margin_change": 100,
        "margin_change_pct": 11.11,
        "margin_buy": 200,
        "margin_sell": 100,
        "short_balance": 50,
        "short_previous": 25,
        "short_change": 25,
        "short_change_pct": 100.0,
        "short_buy": 10,
        "short_sell": 35,
        "source_market": "TWSE"
      },
      "is_favorite": false
    }
  ]
}

members[].price 欄位說明:

欄位 型別 說明
as_of_date string | null 實際使用的最新 OHLC 交易日
close number | null as_of_date 收盤價
change number | null 相對前一交易列的價差
change_pct number | null 相對前一交易列的漲跌幅百分比
five_day_close number | null 第 5 個回看交易列收盤價
five_day_change number | null 相對第 5 個回看交易列的價差
five_day_change_pct number | null 相對第 5 個回看交易列的漲跌幅百分比
twenty_day_close number | null 第 20 個回看交易列收盤價
twenty_day_change number | null 相對第 20 個回看交易列的價差
twenty_day_change_pct number | null 相對第 20 個回看交易列的漲跌幅百分比

members[].margin 欄位說明:

欄位 型別 說明
as_of_date string | null 實際使用的最新融資融券資料日,為 source_trade_date 之前或當日的最新列
margin_balance number | null 融資餘額
margin_previous number | null 前一交易日融資餘額
margin_change number | null margin_balance - margin_previous
margin_change_pct number | null 融資餘額變動百分比;margin_previous 為 0 或缺漏時為 null
margin_buy number | null 融資買進
margin_sell number | null 融資賣出
short_balance number | null 融券餘額
short_previous number | null 前一交易日融券餘額
short_change number | null short_balance - short_previous
short_change_pct number | null 融券餘額變動百分比;short_previous 為 0 或缺漏時為 null
short_buy number | null 融券買進
short_sell number | null 融券賣出
source_market string | null 資料來源市場,例如 TWSE / TPEX

GET /api/theme-recaps/{theme_id}/signals

取得 Theme Recap 的最新訊號 tab,來源包含成員股票的 strategy match、 chip events、technical events。

Query Params:

名稱 類型 預設 說明
market string taiwan 市場範圍
trade_date date latest available 要求的 recap 日期
kind string null 可選:strategy / chip / technical
limit int 20 Page size,上限 100
offset int 0 Pagination offset

Response (200):

{
  "theme_id": "AI 伺服器",
  "total": 3,
  "limit": 20,
  "offset": 0,
  "requested_trade_date": "2026-05-18",
  "source_trade_date": "2026-05-17",
  "is_fallback": true,
  "signals": [
    {
      "kind": "strategy",
      "label": "bullish",
      "ticker": "2330.TW",
      "name": "台積電",
      "title": "long_d5",
      "description": "2330.TW long_d5",
      "strength": 86.0,
      "occurred_at": "2026-05-17"
    }
  ]
}

GET /api/v1/supply-chain/relations/{ticker}

取得個股的供應鏈關係(上下游、客戶、供應商)。 ticker 可帶 raw code 或 .TW / .TWO;response 的 source_ticker、 台股 target_ticker 會回傳 canonical ticker。

Query Params:

名稱 類型 預設 說明
rel_type string null 篩選:upstream / downstream / customer / supplier / midstream
direction string outgoing outgoing(以該 ticker 為起點)或 incoming(以該 ticker 為終點)

Response (200):

[
  {
    "source_ticker": "2330.TW",
    "target_name": "Apple",
    "target_ticker": null,
    "target_is_tw": false,
    "rel_type": "customer",
    "source_section": "customer_supplier",
    "is_bidirectional": true,
    "mention_count": 3
  },
  {
    "source_ticker": "2330.TW",
    "target_name": "ASML",
    "target_ticker": null,
    "target_is_tw": false,
    "rel_type": "upstream",
    "source_section": "supply_chain",
    "is_bidirectional": false,
    "mention_count": 5
  }
]

GET /api/v1/supply-chain/entities/{ticker}

取得個股相關的技術/材料/應用 entity。

Query Params:

名稱 類型 預設 說明
entity_type string null 篩選:technology / material / application

Response (200):

[
  { "entity_name": "CoWoS", "entity_type": "technology", "rel_type": "upstream", "source_section": "supply_chain", "mention_count": 3 },
  { "entity_name": "EUV", "entity_type": "technology", "rel_type": "upstream", "source_section": "supply_chain", "mention_count": 5 },
  { "entity_name": "矽晶圓", "entity_type": "material", "rel_type": null, "source_section": "supply_chain", "mention_count": 2 },
  { "entity_name": "AI伺服器", "entity_type": "application", "rel_type": null, "source_section": "description", "mention_count": 1 }
]

GET /api/v1/supply-chain/themes/{ticker}

取得個股所屬的主題族群。 ticker 可帶 raw code 或交易所尾碼;response ticker 會回傳 canonical ticker (例如 raw 3152 會回傳 3152.TWO)。

Response (200):

[
  { "theme": "5G", "ticker": "3152.TWO", "company_name": "璟德", "role": "related" },
  { "theme": "手機零組件", "ticker": "3152.TWO", "company_name": "璟德", "role": "upstream" }
]

GET /api/v1/supply-chain/theme-members/{theme}

取得主題族群的成員清單。 response ticker 會用同一套 canonicalization(direct → .TW → .TWO → raw code)。

Path Params: theme — 主題名稱(例:AI 伺服器、CoWoS、電動車)

Query Params:

名稱 類型 預設 說明
role string null 篩選:upstream / midstream / downstream / related

Response (200):

[
  { "theme": "CoWoS", "ticker": "3167.TWO", "company_name": "大量", "role": "upstream" },
  { "theme": "CoWoS", "ticker": "3535.TWO", "company_name": "晶彩科", "role": "midstream" },
  { "theme": "CoWoS", "ticker": "2330.TW", "company_name": "台積電", "role": "downstream" }
]

GET /api/v1/supply-chain/path

查找兩個 ticker 之間的最短供應鏈路徑(BFS)。 查找時會用 coverage raw ticker 比對,但回傳 path edge 會轉成 canonical ticker。

Query Params:

名稱 類型 必填 說明
ticker_a string 是 起點 ticker
ticker_b string 是 終點 ticker
max_depth int 否 最大搜尋深度(預設 4)

Response (200):

{
  "ticker_a": "2330.TW",
  "ticker_b": "2454.TW",
  "found": true,
  "path": [
    { "source_ticker": "2330.TW", "target_name": "聯發科", "target_ticker": "2454.TW", "rel_type": "downstream", "is_bidirectional": true }
  ],
  "depth": 1
}
{
  "ticker_a": "2330",
  "ticker_b": "9999",
  "found": false,
  "path": [],
  "depth": 0
}

GET /api/v1/supply-chain/search

RAG 語義搜尋(Coverage 研究報告全文檢索)。

🔒 Auth: 需登入(Authorization: Bearer <access_token>)。每次查詢會呼叫付費 OpenAI embedding,故需驗證。無效 token 回 401;未帶 Authorization header 回 422。

Query Params:

名稱 類型 必填 說明
q string 是 搜尋文字(例:液冷散熱、CoWoS 上游、Apple 代工)
chunk_type string 否 document / section_desc / section_sc / section_cs
sector string 否 篩選產業
top_k int 否 回傳數量(預設:document=3, section=5)

Response (200):

[
  {
    "doc_id": "sec_2330_section_sc",
    "ticker": "2330",
    "chunk_type": "section_sc",
    "section_name": "供應鏈位置",
    "content": "上游 (設備/原料): ASML (獨家 EUV 供應商)...",
    "score": 0.92
  }
]

共通錯誤碼:

狀態碼 說明
404 該 ticker 無 coverage 資料
422 參數格式錯誤
500 服務內部錯誤

23. 更新紀錄 Updates

Prefix: /api/updates

GET /api/updates/recent

取得近期資料更新紀錄。

Query Params:

名稱 類型 預設 說明
limit int 50 筆數(1-200)

Response — UpdateRun[]:

[
  {
    "id": 1,
    "job_name": "daily_price_update",
    "status": "completed",
    "reason": null,
    "message": "Updated 1500 tickers",
    "started_at": "2024-03-15T06:00:00",
    "ended_at": "2024-03-15T06:15:00"
  }
]

GET /api/updates/schedule-summary

取得某一天(台北日曆日)排程執行彙整:當天每個排程 job 與其逐步(每隻 API)的開始/結束時間與成功失敗狀態。供 admin 每日彙整信與前端 dashboard 共用同一份資料(issue #439)。

Query Params:

名稱 類型 預設 說明
date date (YYYY-MM-DD) 今天(Asia/Taipei) 要彙整的台北日曆日

overall_status:success(全部成功)/ partial(部分失敗或仍進行中)/ failed(全部失敗)/ empty(當天無排程)。每個 task 的 in_progress=true 表示 summary 產生時該步驟仍在執行(ended_at 為 null)。

Response — ScheduleSummaryOut:

{
  "day": "2026-06-16",
  "overall_status": "partial",
  "jobs": [
    {
      "job_name": "daily_update",
      "status": "success",
      "run_id": 525,
      "started_at": "2026-06-16T07:30:00+00:00",
      "ended_at": "2026-06-16T07:53:00+00:00",
      "duration_seconds": 1380.0,
      "reason": "scheduled:daily:retry=0",
      "message": "Daily update complete",
      "tasks": [
        {
          "task_name": "ohlc",
          "status": "success",
          "started_at": "2026-06-16T07:30:00+00:00",
          "ended_at": "2026-06-16T07:35:00+00:00",
          "duration_seconds": 300.0,
          "in_progress": false,
          "error_message": null
        },
        {
          "task_name": "dividend_events",
          "status": "failed",
          "started_at": "2026-06-16T07:35:00+00:00",
          "ended_at": "2026-06-16T07:35:02+00:00",
          "duration_seconds": 2.0,
          "in_progress": false,
          "error_message": "twse 500"
        }
      ]
    }
  ]
}

24. Health Check

GET /health

系統健康檢查。會實際對資料庫執行一次 SELECT 1 存活查詢,逾時上限由 config/core.yaml 的 api.health.db_probe_timeout_seconds 控制。

此端點是 Docker healthcheck、CD 部署閘門(含自動 rollback 判準)與 SIT DB 切換驗證的共同判準,故資料庫不可用時回 503 而非 200。

Response 200 OK — 存活查詢成功:

{
  "status": "ok",
  "timestamp": "2026-07-23T02:31:45.746607",
  "database": "connected",
  "services": {
    "llm": "ok",
    "agents": "ok"
  }
}

Response 503 Service Unavailable — 存活查詢失敗或逾時:

{
  "status": "degraded",
  "timestamp": "2026-07-23T02:31:45.746607",
  "database": "disconnected",
  "services": {
    "llm": "ok",
    "agents": "limited"
  }
}
欄位 型別 說明
status string ok(健康)/ degraded(資料庫不可用)
timestamp string ISO 8601
database string connected / disconnected
services.llm string ok / unavailable
services.agents string ok / limited

GET /

根路徑,回傳 API metadata。


25. 錯誤處理

HTTP Status Codes

Code 說明
200 成功
400 請求參數錯誤
401 未認證或 token 過期
403 權限不足
404 資源不存在
422 請求 body 驗證失敗
500 伺服器錯誤

Error Response Format

失敗 body 的形狀取決於是誰拒絕的,完整六種見 §通用 Response 格式。本節端點的絕大多數 4xx/5xx 走全域 handler,形狀為:

{
  "error": {
    "code": "描述錯誤的訊息",
    "message": "描述錯誤的訊息",
    "details": {}
  }
}

認證失敗(AuthError)走同一個 envelope,code 是錯誤代碼而非訊息:

{
  "error": {
    "code": "INVALID_TOKEN",
    "message": "Invalid access token",
    "details": {}
  }
}

舊版本檔曾記載 {"detail": ...} 與 error_code 同層欄位,兩者皆非本 API 送出的形狀。


附錄:Enum 值快速查表

direction

"bullish" | "bearish"

strategy_key (unified)

"long_d1" | "short_d1" | "long_d5" | "short_d5" | "long_d10" | "short_d10" | "long_d20" | "short_d20" | "long_d40" | "short_d40" | "long_d60" | "short_d60"

analysis_type

"comprehensive" | "technical" | "trend"

timeframe

"1_month" | "3_months" | "6_months" | "1_year"

response_mode

"stream" (SSE) | "json"

MAS target

"orchestrator" | "technical" | "chip" | "financial"

backtest domain

"technical" | "chip" | "financial"

portfolio action

"buy" | "sell"

group_type

"industry" | "sector" | "concept"

market_regime

"bull_trend" | "bear_trend" | "range_bound" | "recovery" | ...(依模型輸出)

job_status

"pending" | "running" | "completed" | "failed"


27. 統一策略 Strategy

統一策略引擎(Unified Strategy Engine)取代 V3/V4 雙軌,提供單一 API。

未指定 trade_date 時,matches、recommendations、summary 與個股 overview 一律使用同一個 authoritative snapshot:MAX(strategy_match.trade_date)。 recommendations 不會因最新日沒有推薦列而退回前一日。 若 authoritative snapshot 本身不存在,預設 recommendations 與個股 overview 回傳 503;若 snapshot 存在但沒有推薦列,recommendations 則正常回傳空陣列。

GET /api/strategy/matches/{ticker}

個股策略匹配結果。

參數 類型 說明
ticker path 股票代碼 (e.g. 2330.TW)
trade_date query 日期 YYYY-MM-DD,預設最新
source_window query 2y(預設)/ all,見 §1 通用說明
include_below_floor query true 回傳所有品質層級(預設 false,僅回傳 qualified 及舊版資料)

Response: StrategyMatchResponse[]

欄位 類型 說明
ticker string 股票代碼
trade_date string 交易日
strategy_key string 策略鍵 (long_d1, short_d5, ...)
rule_id string? 規則 ID
scope string? global / group / stock / supply_chain / user_group
score float 綜合分數 (HR + EV bonus)
ev_bonus float? EV 加成項
expected_value float? 期望值(後相容;新介接請從 metrics_2y.expectancy 取得)
metrics_2y object? 2y 窗口 8 項指標(hit_rate, expectancy, ...);見 §1
metrics_all object? 全期窗口 8 項指標;見 §1
support_count int? 樣本數
rank int? 策略內排名 (1=最佳)
is_primary bool 是否為該策略最佳規則
is_recommended bool 是否為推薦
domain string? 規則來源 (TA / CT / IR / COM)
regime_slice string? 產出時的 regime 切片 (all / bull / bear / range)
family_id string? IR family ID(僅 IR domain)
commodity_code string? 原物料代碼(僅 COM domain,如 COPPER)
user_group_id string? 使用者群組 ID(僅 scope=user_group)
visibility string? public / private(user_group 預設 private)

GET /api/strategy/recommendations

首頁總覽推薦(is_recommended = TRUE 的 primary rules)。

參數 類型 說明
trade_date query 日期,預設最新
source_window query 2y(預設)/ all,見 §1 通用說明
include_below_floor query true 回傳所有品質層級(預設 false,僅回傳 qualified 及舊版資料)

Response: RecommendationResponse[]

GET /api/strategy/summary

策略統計摘要(每個 strategy_key 的 match 數量、平均 HR、平均 score)。

參數 類型 說明
trade_date query 日期,預設最新

Response: StrategySummaryResponse[]

strategy_key 對照表

strategy_key 說明
long_d1 日線做多
short_d1 日線做空
long_d5 週線做多
short_d5 週線做空
long_d10 雙週線做多
short_d10 雙週線做空
long_d20 月線做多
short_d20 月線做空
long_d40 雙月線做多
short_d40 雙月線做空
long_d60 季線做多
short_d60 季線做空

GET /api/strategy/overview/{ticker}

個股十二策略總覽,固定回傳 12 個策略狀態(含未觸發),附帶 primary rule + full supporting rules + conditions。每筆 rule 以 active 區分是否在指定交易日觸發。

參數 類型 說明
ticker path 股票代碼 (e.g. 2330.TW)
trade_date query 日期 YYYY-MM-DD,預設最新
source_window query 2y(預設)/ all,見 §1 通用說明
include_below_floor query true 回傳所有品質層級(預設 false,僅回傳 qualified 及舊版資料)
include_inactive_rules query true 額外回傳「對應此 ticker」的未觸發 catalog rules;預設 true。範圍包含 c.ticker = ticker 的規則、ticker 所屬 eligible group 的 scope=group 規則、ticker 所屬 validated supply chain 的 scope=supply_chain 規則;未命中的 generic global 規則不展開

Response: StrategyOverviewResponse

欄位 類型 說明
ticker string 股票代碼
trade_date string 交易日
strategies StrategyStatus[] 12 個策略狀態

StrategyStatus:

欄位 類型 說明
strategy_key string 策略鍵
label string 中文標籤
direction string long / short
triggered bool 是否觸發
primary_rule SupportingRule? 最佳規則(null = 未觸發)
supporting_rules SupportingRule[] 除 primary_rule 以外的完整規則清單;未觸發規則會以 active=false 回傳

SupportingRule:

欄位 類型 說明
rule_id string? 規則 ID
catalog_key int? 目錄主鍵;legacy match 可能為 null
scope string? global / group / stock / supply_chain / user_group
active bool 是否在本次 ticker + trade_date 命中
score float 綜合分數;active=false 的 catalog-only 規則為 0.0
expected_value float? 期望值(後相容欄位;新介接請從 metrics_2y.expectancy 取得)
metrics_2y object? 2y 窗口 8 項指標(hit_rate, expectancy, profit_factor, ...);見 §1
metrics_all object? 全期窗口 8 項指標;見 §1
conditions object 觸發條件 {condition_name: true}

Story-first 策略(Story Strategy)

以「當日 Story Set」為單位、用歷史事件報酬統計驅動的策略檢視,資料來自已發布(status=published)的 Story Strategy 快照。端點會檢查 published as_of_date、同 rule version 最新的 ses_story_daily.trading_date 與 authoritative strategy_match.trade_date 三者完全相同;source 缺失或日期不一致時回傳 503,不提供過期結果。個股 overview 只讀 published 當日的 exact Story Set,不回退到 ticker 的舊日期。與上述 rule-based 策略並行,前綴 /api/strategy/story。

不適用 §1 的 Contract D。 本子節 response 的 metrics_2y / metrics_all 為 StoryMetrics(event_count、average_return?、up_count、average_up_return?、down_count、average_down_return?),與 rule-based 策略的 8 欄指標(hit_rate / expectancy / profit_factor …)是完全不同的結構;且這些端點不接受 source_window 參數。

GET /api/strategy/story/overview/{ticker}

個股 Story-first 統計總覽:回傳該個股當日 Story Set 內每個 story 的歷史報酬統計,以及「精確 Story Set」(整組 story 同時命中)的統計。每項統計橫跨多個 horizon 與 stock / market 兩個對照基準、2y / all 兩個窗口。

參數 類型 說明
ticker path 股票代碼 (e.g. 2330.TW)
trade_date query 僅提供已發布快照日;不給則用該日,帶入其他日期回 404,不回退 ticker 舊日
rule_version query SES rule version,預設為當前發布版本

錯誤: 404 帶入的日期非已發布服務日,或該日期無此 ticker 的 Story Set;503 已發布快照不可用、該世代資料不完整,或 Story source revision 尚未收斂(發布世代/source/derived 三個 revision 不相等,或存在 historical dirty 標記)。

Response: StoryStrategyOverviewResponse

欄位 類型 說明
ticker string 股票代碼
trade_date string Story Set 交易日
stats_as_of_date string 統計世代的 as-of 日期
rule_version string SES rule version
stories StoryOverviewItem[] 當日 Story Set 內各單一 story 的統計
exact_story_set ExactStorySetOverview 整組 story 同時命中的精確統計

StoryOverviewItem: domain、story_code、story_name?、direction?、horizons: StoryHorizonMetrics[] ExactStorySetOverview: story_set_hash、story_count、story_keys[]、horizons: StoryHorizonMetrics[] StoryHorizonMetrics: days(horizon 天數,如 1/5/10/20/40/60)、stock: {metrics_2y, metrics_all}、market: {metrics_2y, metrics_all} StoryMetrics: event_count、average_return?、up_count、average_up_return?、down_count、average_down_return?

GET /api/strategy/story/recommendations

依 2 年 evidence 排序的 Story-first 策略推薦,結果按 12 個 strategy_key(long/short × d1/d5/d10/d20/d40/d60)分桶,每桶內依 score 排名。

參數 類型 說明
trade_date query V1 僅提供已發布快照日;帶入其他日期回 404
rule_version query SES rule version,預設為當前發布版本
strategy_key query 限定單一策略鍵(12 個 enum 之一);不給則回全部
limit_per_strategy query 每個策略回傳上限,1–100,預設 30

錯誤: 404 帶入的日期非已發布服務日;503 已發布快照不可用。

Response: StoryRecommendationsResponse

欄位 類型 說明
trade_date string 服務日(已發布快照日)
stats_as_of_date string 統計世代的 as-of 日期
rule_version string SES rule version
score_version string 排序 score 版本
score_window string 固定 2y
filters_applied object {strategy_key?, limit_per_strategy}
strategies StoryRecommendationBucket[] 各策略鍵分桶

StoryRecommendationBucket: strategy_key、direction(long/short)、horizon、total、items: StoryRecommendationItem[] StoryRecommendationItem: rank、ticker、stock_name?、score、candidate_count、current_story_set、selected_evidence SelectedStoryEvidence: type(single_story/exact_story_set)、domain?、story_code?、story_set_hash?、story_keys?、event_count、hit_count、hit_rate、directional_average_return、stock_metrics_2y、market_metrics_2y


28. 原物料 Commodities

跨資產原物料及石油價格追蹤。提供 WTI、Brent、天然氣、黃金、白銀、銅的歷史價格及技術指標。

27.1 GET /api/commodities/list

列出所有追蹤的商品。

Query Parameters:

參數 類型 預設 說明
active_only bool true 只顯示啟用的商品

Response: CommodityInfo[]

欄位 類型 說明
symbol string 商品代號(WTI, GOLD, COPPER 等)
name string 商品名稱(中文)
english_name string? 英文名稱
category string 分類:energy, precious_metals, industrial_metals
series_type string 資料來源類型:fred_spot, yfinance_front_month
unit string? 單位(USD/barrel, USD/troy_oz, USD/lb)
is_active bool 是否啟用

27.2 GET /api/commodities/{symbol}/ohlc

取得商品 OHLC 歷史價格。

Path Parameters:

參數 類型 說明
symbol string 商品代號(例如 WTI, GOLD)

Query Parameters:

參數 類型 預設 範圍 說明
days int 60 1-2000 回傳最近 N 天

Response: CommodityOHLC[]

欄位 類型 說明
symbol string 商品代號
trade_date date 交易日期
open float? 開盤價(FRED 來源為 null)
high float? 最高價(FRED 來源為 null)
low float? 最低價(FRED 來源為 null)
close float? 收盤 / 現貨價
volume int? 成交量(FRED 來源為 null)
data_source string 資料來源:fred 或 yfinance

27.3 GET /api/commodities/{symbol}/snapshot

取得商品技術指標快照。

Path Parameters:

參數 類型 說明
symbol string 商品代號

Query Parameters:

參數 類型 預設 範圍 說明
days int 30 1-500 回傳最近 N 天

Response: CommoditySnapshot[]

欄位 類型 說明
symbol string 商品代號
trading_date date 交易日期
close float? 收盤價
sma_5 float? 5 日均線
sma_10 float? 10 日均線
sma_20 float? 20 日均線
sma_60 float? 60 日均線
rsi float? 14 日 RSI
atr float? 14 日 ATR(FRED 來源為 null)
pct_change_1d float? 1 日漲跌幅
pct_change_5d float? 5 日漲跌幅
pct_change_20d float? 20 日漲跌幅
pct_change_60d float? 60 日漲跌幅
trend_20d string? 20 日趨勢:up, down, flat
trend_60d string? 60 日趨勢:up, down, flat
above_sma_20 bool? 收盤 > SMA20
above_sma_60 bool? 收盤 > SMA60

27.4 GET /api/commodities/snapshot/latest

取得所有商品最新一日的技術指標快照。

Response: CommoditySnapshot[](每個商品一筆,最新日期)

與 27.3 相同的 response schema,但不需要指定 symbol,回傳所有商品的最新狀態。適合用於跨資產訊號總覽面板。


28. 規則說明 Rule Explanation

由 rule_id 查 strategy_rule_catalog 取得 conditions JSON,翻譯為可讀的中文條件說明。 V12 起 rule_id 為 32 字元 MD5 hash(V11 path-string 格式已淘汰)。

28.1 GET /api/strategy/rules/{rule_id}

取得單一規則的條件說明。

Path Parameters: - rule_id (string, required) — 規則 ID(MD5 hash),如 2e99c2e78db812ae9d1bb593403a5a3f

Response: RuleExplanationResponse

{
  "rule_id": "2e99c2e78db812ae9d1bb593403a5a3f",
  "conditions": ["cond__ma_bullish"],
  "conditions_zh": ["均線多頭排列"],
  "description": "當 均線多頭排列 時觸發"
}

Error: - 404 — rule_id 不在 strategy_rule_catalog

28.2 POST /api/strategy/rules/explain

批次查詢多筆規則說明(單一 ANY(:rule_ids) 查詢,無 N+1)。

Request Body:

{
  "rule_ids": [
    "2e99c2e78db812ae9d1bb593403a5a3f",
    "1ed44d0ab8c1c8029eeca7d2b52c525b"
  ]
}
  • rule_ids (string[], required) — 最少 1 筆,上限由 strategy.rule_explanation.batch_max 設定(預設 100)

Response: RuleExplanationBatchResponse

{
  "items": [
    {
      "rule_id": "2e99c2e78db812ae9d1bb593403a5a3f",
      "conditions": ["cond__ma_bullish"],
      "conditions_zh": ["均線多頭排列"],
      "description": "當 均線多頭排列 時觸發"
    },
    {
      "rule_id": "1ed44d0ab8c1c8029eeca7d2b52c525b",
      "conditions": ["cond__rsi_lt_30"],
      "conditions_zh": ["RSI < 30(超賣)"],
      "description": "當 RSI < 30(超賣) 時觸發"
    }
  ]
}

行為說明: - 輸出順序與輸入一致 - 不去重(重複 rule_id 會回傳重複結果) - 未知的 condition token 不報錯,conditions_zh 回傳原 token - 不在 catalog 的 rule_id 在批次中回傳空 conditions + 空 conditions_zh + 空 description(不報錯,方便前端對映)

Error: - 422 — request body 不合法(空陣列、超過 strategy.rule_explanation.batch_max 上限)


29. MAS Execution Trace

MAS 執行追蹤 API。所有 endpoints 為 read-only,且僅限 active admin 查詢 MAS 分析執行的完整 LLM call trace。 若資料庫尚未套用 members.is_admin migration,授權會 fail closed 並回傳 503 ADMIN_SCHEMA_MISSING。

Prefix: /api/mas/traces

29.1 GET /api/mas/traces/executions

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。active non-admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

列出 MAS 分析執行紀錄。

參數 型別 預設 說明
ticker string? — 依股票代號篩選
status string? — 依狀態篩選(running / success / failed)
started_after datetime? — 起始時間下限
started_before datetime? — 起始時間上限
limit int 20 每頁筆數(1-100)
offset int 0 分頁偏移

Response:

{
  "items": [
    {
      "execution_id": "uuid-string",
      "ticker": "2330.TW",
      "started_at": "2026-03-26T14:30:00Z",
      "ended_at": "2026-03-26T14:30:10Z",
      "status": "success",
      "activated_agents": ["technical", "chip", "financial", "orchestrator"],
      "total_tokens": 5200,
      "total_input_tokens": 3500,
      "total_output_tokens": 1700,
      "llm_call_count": 9,
      "agent_count": 4
    }
  ],
  "total": 42
}

29.2 GET /api/mas/traces/executions/{execution_id}

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。active non-admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

查詢單次執行的完整詳情,包含所有 LLM call trace。

參數 型別 預設 說明
sort string timeline timeline:依 started_at 排序;agent:依 agent_name + call_sequence 排序

Response:

{
  "execution": { "...ExecutionSummary..." },
  "agent_summaries": [
    {
      "agent_name": "technical",
      "call_count": 3,
      "input_tokens": 1200,
      "output_tokens": 600,
      "total_tokens": 1800
    }
  ],
  "calls": [
    {
      "id": 1,
      "execution_id": "uuid-string",
      "ticker": "2330.TW",
      "agent_name": "technical",
      "stage": "main_analysis",
      "phase": null,
      "vrr_iteration": null,
      "call_sequence": 1,
      "provider": "gemini",
      "model": "gemini-2.5-flash",
      "system_prompt": "...",
      "user_prompt": "...",
      "raw_response_text": "...",
      "input_tokens": 500,
      "output_tokens": 200,
      "total_tokens": 700,
      "latency_ms": 2340,
      "success": true
    }
  ]
}

29.3 GET /api/mas/traces/executions/{execution_id}/calls

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。active non-admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

查詢單次執行的 LLM call list,可篩選。

參數 型別 預設 說明
agent_name string? — 篩選特定 agent
stage string? — 篩選特定 stage(main_analysis / vrr_review / vrr_revise / orchestrate 等)
sort string timeline 排序模式(指定 agent_name 時自動切 agent)

Response: LlmCallResponse[]

29.4 GET /api/mas/traces/tickers/{ticker}

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。active non-admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

查詢某支股票的歷史執行紀錄。

參數 型別 預設 說明
started_after datetime? — 起始時間下限
started_before datetime? — 起始時間上限
limit int 20 每頁筆數(1-100)
offset int 0 分頁偏移

Response: 同 29.1

29.5 GET /api/mas/traces/executions/{execution_id}/summary

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。active non-admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

Token 用量明細,含 per-agent、per-stage、per-VRR-iteration breakdown。

Response:

{
  "execution_total": {
    "total_input_tokens": 3500,
    "total_output_tokens": 1700,
    "total_tokens": 5200,
    "llm_call_count": 9,
    "agent_count": 4
  },
  "per_agent": [
    { "agent_name": "technical", "call_count": 3, "input_tokens": 1200, "output_tokens": 600, "total_tokens": 1800 }
  ],
  "per_stage": [
    { "stage": "main_analysis", "call_count": 4, "input_tokens": 2000, "output_tokens": 800, "total_tokens": 2800 }
  ],
  "per_iteration": [
    { "agent_name": "technical", "vrr_iteration": 1, "call_count": 2, "input_tokens": 600, "output_tokens": 300, "total_tokens": 900 }
  ]
}

29.6 Stage 標準值

stage 說明
main_analysis Agent 主分析
vrr_review VRR 驗證
vrr_revise VRR 修正
vrr_finalize VRR 最終確認
orchestrate Orchestrator 綜合分析
verify 驗證步驟
fallback 退回路徑

30. Data Status(資料更新狀態)

30.1 GET /api/data-status

回報今日所有 pipeline 資料表的更新狀態。

Query Parameters:

參數 類型 必填 說明
date string (YYYY-MM-DD) 否 查詢日期,預設今日(Asia/Taipei)

Response:

{
  "report_date": "2026-03-31",
  "timestamp": "2026-03-31T17:30:00+08:00",
  "pipeline": {
    "job_name": "daily_update",
    "status": "success",
    "started_at": "2026-03-31 15:30:08",
    "ended_at": "2026-03-31 15:46:09",
    "message": "Daily update complete"
  },
  "tables": [
    {
      "name": "stock_ohlc",
      "category": "price",
      "latest_date": "2026-03-31",
      "row_count": 2103,
      "prev_date": "2026-03-30",
      "prev_count": 2130,
      "status": "fresh"
    }
  ],
  "summary": {
    "total_tables": 10,
    "fresh": 7,
    "stale": 3,
    "empty": 0
  }
}

Status 判定邏輯:

Status 條件
fresh latest_date >= report_date
stale latest_date < report_date
empty 近 7 天內無任何資料

涵蓋資料表:

Category Table Description
price stock_ohlc 股價 OHLC
price technical_indicators 技術指標
chip institutional_investors 三大法人
chip margin_balance 融資融券
chip daily_chip_snapshot 籌碼快照
fundamental valuation_metrics 估值指標
fundamental daily_financial_snapshot 財務快照
technical daily_technical_snapshot 技術快照
strategy strategy_match 策略配對
commodity commodity_ohlc 商品價格

30B. Sync Status(market × capability 觀測)

與 /api/data-status 的差異: - /api/data-status:job 層級 + table freshness(粗顆粒)。回答「今天 daily pipeline 跑完了沒,哪些表 stale」。 - /api/sync-status:market × capability × run_date 細顆粒。回答「taiwan 今天 chip_snapshot 跑成功嗎?us 過去 30 天哪幾天 OHLC 失敗?」

兩者並存,前端依需求挑選。

30B.1 GET /api/sync-status

回傳某一天所有 (market, capability) 的 pipeline 狀態。

Query Parameters:

參數 類型 必填 說明
date string (YYYY-MM-DD) 否 查詢日期,預設今日(Asia/Taipei)

Response:

{
  "report_date": "2026-05-11",
  "rows": [
    {
      "market": "taiwan",
      "capability": "ohlc",
      "ticker": null,
      "run_date": "2026-05-11",
      "status": "success",
      "error_message": null,
      "retry_count": 0,
      "updated_at": "2026-05-11T08:30:42+08:00"
    }
  ],
  "summary": {
    "total": 12,
    "success": 11,
    "failed": 1,
    "skipped": 0,
    "skipped_upstream_failure": 0
  }
}

注意:status: "success" 的 row 中,error_message 仍可能帶非阻斷性的逐筆失敗計數(例如 chip_snapshot 的 "N ticker(s) failed to compute")——代表該日 snapshot 已落地、僅少數 ticker 無法計算,並非阻斷性失敗。

30B.2 GET /api/sync-status/history

過濾條件全部 optional。未帶 date_from/date_to 時,預設覆蓋最近 default_history_days 天(由 config/core.yaml 之 sync_status.default_history_days 控制,預設 30)。

Query Parameters:

參數 類型 必填 說明
market string 否 市場名稱(taiwan / us / japan)
capability string 否 能力名稱(ohlc / chip_snapshot / financial_snapshot / commodity / etf_holdings / ohlc_history_seed(舊版新上市歷史回補紀錄,保留查詢;每日更新已停止寫入、登記與重試))
ticker string 否 指定 ticker;ohlc_history_seed 的舊紀錄為 per-ticker row,其餘 capability 目前仍只寫 market-level row(ticker=NULL),保留作為 forward-compat
date_from string (YYYY-MM-DD) 否 起始日(含),預設 date_to - default_history_days
date_to string (YYYY-MM-DD) 否 結束日(含),預設今日(Asia/Taipei)
status string (可重複) 否 success / failed / skipped / skipped_upstream_failure。重複帶可組合:?status=failed&status=skipped

Constraints: - date_from <= date_to,否則回 400。 - (date_to - date_from) <= max_history_days(預設 365),超過回 400。

常見用法: - 單格 30 天 trend(B 模式):?market=taiwan&capability=chip_snapshot - 最近 30 天所有失敗:?status=failed - 跨市場 4 月所有未成功:?date_from=2026-04-01&date_to=2026-04-30&status=failed&status=skipped

Response:

{
  "rows": [
    {
      "market": "taiwan",
      "capability": "chip_snapshot",
      "ticker": null,
      "run_date": "2026-05-11",
      "status": "success",
      "error_message": null,
      "retry_count": 0,
      "updated_at": "2026-05-11T08:30:42+08:00"
    }
  ],
  "summary": {
    "total": 30,
    "success": 28,
    "failed": 2,
    "skipped": 0,
    "skipped_upstream_failure": 0
  }
}

Rows ORDER BY (market, capability, COALESCE(ticker, ''), run_date DESC) — same key as the matrix endpoint so per-cell rows stay contiguous when per-ticker tracking is added.


31. 籌碼面 Recap (Chip Recap)

每日籌碼面市場總覽與族群資金流向。Pipeline 每日 15:30 後產出。

31.1 GET /api/chip-recap/latest

取得最新一日的籌碼面市場摘要。籌碼日期為不晚於官方交易日、回應 sources 列出的 8 個大盤日來源(TWSE 三大法人金額 4 類、大盤融資、融券、借券、外資期貨)任一已有資料的最新一日;新一日完全沒有來源時維持前一日(含分析),新一日部分到達時整份換日、未到欄位與分析為 null。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 同 31.2,自動查詢最新可用日期。

31.2 GET /api/chip-recap/{trade_date}

取得指定日期的籌碼面市場摘要。若該日無資料,自動 fallback 至最近可用日。

Path Parameters:

參數 類型 說明
trade_date date (YYYY-MM-DD) 交易日

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response:

{
  "trade_date": "2026-04-07",
  "requested_trade_date": "2026-04-07",
  "source_trade_date": "2026-04-07",
  "is_fallback": false,
  "market": "taiwan",
  "n_stocks": 1850,
  "total_foreign_net_value": 12345678.0,
  "total_trust_net_value": -2345678.0,
  "total_dealer_net_value": 567890.0,
  "total_market_net_value": 10567890.0,
  "total_margin_change_value": 1968695000,
  "total_short_change_value": null,
  "market_context": {
    "official_market_margin_balance_amount": 329543807000,
    "official_market_margin_change_amount": 1968695000,
    "official_market_margin_balance_lots": 8738210,
    "official_market_margin_change_lots": 23928,
    "official_market_margin_source": "FINMIND_TaiwanStockTotalMarginPurchaseShortSale",
    "official_market_margin_date": "2025-01-02",
    "official_market_margin_status": "available",
    "foreign_tx_futures_long_oi": 9023,
    "foreign_tx_futures_short_oi": 52279,
    "foreign_tx_futures_net_oi": -43256,
    "foreign_tx_futures_net_short_oi": 43256,
    "foreign_tx_futures_source": "FINMIND_TaiwanFuturesInstitutionalInvestors",
    "foreign_tx_futures_date": "2025-01-02",
    "foreign_tx_futures_status": "available"
  },
  "top_bullish_stocks": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "group_name": "AI 伺服器",
      "total_net_value": 11000000.0,
      "foreign_net_value": 10000000.0,
      "trust_net_value": 2000000.0,
      "dealer_net_value": -1000000.0,
      "flow_score": 11000000.0,
      "reasons": ["foreign_net_buy", "trust_continuous_buy"],
      "dominant_actor": "foreign"
    }
  ],
  "top_bearish_stocks": [
    {
      "ticker": "2454.TW",
      "name": "聯發科",
      "group_name": "5G",
      "total_net_value": -8250000.0,
      "foreign_net_value": -7500000.0,
      "trust_net_value": -1500000.0,
      "dealer_net_value": 750000.0,
      "flow_score": -8250000.0,
      "reasons": ["foreign_net_sell", "trust_continuous_sell"],
      "dominant_actor": "foreign"
    }
  ],
  "top_big_holder_1000_increases": [
    {
      "ticker": "9911.TW",
      "name": "千張增",
      "current_ratio": 11.0,
      "change": 1.2,
      "current_shares": 120000000,
      "shares_change": 30000000,
      "ownership_data_date": "2026-04-05",
      "ownership_data_age_days": 1,
      "levels": ["15"]
    }
  ],
  "top_big_holder_1000_decreases": [],
  "top_retail_50_increases": [],
  "top_retail_50_decreases": [
    {
      "ticker": "9914.TW",
      "name": "散戶減",
      "current_ratio": 6.0,
      "change": -0.7,
      "current_shares": 9000000,
      "shares_change": -3000000,
      "ownership_data_date": "2026-04-05",
      "ownership_data_age_days": 1,
      "levels": ["1", "2", "3", "4", "5", "6", "7", "8"]
    }
  ]
}

is_fallback=true 表示 source_trade_date 與 requested_trade_date 不同(使用了 fallback 日期)。

市場層級籌碼欄位:

total_margin_change_value 只代表官方/市場層級融資金額變動;沒有 summary_json.market_context.official_market_margin_status="available" 時必須是 null。後端不得由個股 margin_balance 或 daily_chip_snapshot.margin_change 自行加總推導大盤融資。外資台指期口數來自 FinMind TaiwanFuturesInstitutionalInvestors 的 data_id=TX。

欄位 類型 說明
total_margin_change_value number | null 官方/市場層級融資金額變動;pending/error/no_data 時為 null
total_short_change_value number | null 官方/市場層級融券金額變動;目前沒有官方 TWD source 時為 null
summary_json.market_context.official_market_margin_balance_amount number | null 大盤融資餘額金額,TWD
summary_json.market_context.official_market_margin_change_amount number | null 大盤融資餘額金額變動,TWD
summary_json.market_context.official_market_margin_balance_lots number | null 大盤融資餘額交易單位
summary_json.market_context.official_market_margin_status string available / pending_source / source_error / no_data
summary_json.market_context.foreign_tx_futures_short_oi number | null 外資台指期未平倉空單口數
summary_json.market_context.foreign_tx_futures_net_short_oi number | null 外資台指期淨空單口數,空單減多單
summary_json.market_context.foreign_tx_futures_status string available / pending_source / source_error / no_data

融資融券口徑日期欄位:

個股融資融券來源(margin_balance)每交易日 21:30 才落地,晚於盤後其他籌碼資料。 排程在該日資料落地前不會產生籌碼快照,因此正常情況下這兩個欄位的 age 為 0 (即當日資料)。它們留在 payload 供除錯與可觀測性使用。

注意:槓桿風險引擎「資料過舊就整段跳過」的判斷(MARGIN_STALE_SKIP_AGE)發生在 寫入快照之前,讀的是 loader 當下算出的 age,不是這裡的 payload 欄位。 payload 這份是純下游副本,沒有任何流程消費它。

只有槓桿類項目帶這兩個欄位,而且兩處的存在性語意不同:

位置 帶欄位的項目 存在性
summary_json.stock_event_summary margin_surge、margin_flush、short_surge 三個分桶 無口徑資訊時整組 key 不出現
summary_json.risk_signals[] margin_up_without_institutional_support、short_build_up、institutional_sell_with_margin_rise key 一律存在,無值時為 null
欄位 類型 說明
margin_data_trade_date string (YYYY-MM-DD) | null 該筆融資融券數字實際所屬的交易日
margin_data_age_days integer | null 與 recap trade_date 相差的日曆天數;正常為 0

消費端一律用「key 是否存在」判斷,不可假設 key 一定在。 risk_signals[] 另外兩種訊號 —— group_inflow_but_leverage_heating 與 buy_to_sell_in_leading_group —— 兩個 key 都沒有,直接 signal["margin_data_age_days"] 會 KeyError。特別注意 group_inflow_but_leverage_heating 是第一個被加入的,常常就是 risk_signals[0]。 非槓桿分桶(continuous_buy / continuous_sell / sell_to_buy / buy_to_sell)同樣不帶這兩個欄位。

法人大買 / 法人大賣欄位:

欄位 類型 說明
top_bullish_stocks InstitutionalStockFlowItem[] 當日三大法人合計淨買超前 10 大股票,僅包含 total_net_value > 0,依金額由大到小排序;金額相同時依 ticker 穩定排序
top_bearish_stocks InstitutionalStockFlowItem[] 當日三大法人合計淨賣超前 10 大股票,僅包含 total_net_value < 0,依金額由小到大排序(賣壓最大在前);金額相同時依 ticker 穩定排序
top_big_holder_1000_increases OwnershipChangeRankingItem[] 千張以上大戶(TDCC level 15)持股比例增加前 10 名;僅包含 change > 0,依 change DESC, ticker ASC 排序
top_big_holder_1000_decreases OwnershipChangeRankingItem[] 千張以上大戶(TDCC level 15)持股比例減少前 10 名;僅包含 change < 0,依 change ASC, ticker ASC 排序
top_retail_50_increases OwnershipChangeRankingItem[] 50 張以下散戶(TDCC levels 1-8)持股比例增加前 10 名;僅包含 change > 0,依 change DESC, ticker ASC 排序
top_retail_50_decreases OwnershipChangeRankingItem[] 50 張以下散戶(TDCC levels 1-8)持股比例減少前 10 名;僅包含 change < 0,依 change ASC, ticker ASC 排序

InstitutionalStockFlowItem:

欄位 類型 說明
ticker string 股票代碼
name string | null 股票名稱
group_name string | null 主要題材 / 群組名稱
total_net_value number 三大法人合計買賣超金額(正=買超,負=賣超)
foreign_net_value number 外資買賣超金額
trust_net_value number 投信買賣超金額
dealer_net_value number 自營商買賣超金額
flow_score number 排序分數;目前等於 signed total_net_value
reasons string[] 後端整理的原因標籤,例如 foreign_net_buy, trust_continuous_buy, foreign_buy_to_sell
dominant_actor "foreign" \| "trust" \| "dealer" \| "mixed" 以絕對金額判定的主導法人

OwnershipChangeRankingItem:

欄位 類型 說明
ticker string 股票代碼
name string | null 股票名稱
current_ratio number | null 該 bucket 最新持股比例加總;千張以上為 level 15,50 張以下為 levels 1-8
change number 該 bucket 持股比例變動加總,單位為百分點;增加榜為正,減少榜為負
current_shares integer | null 該 bucket 在 ownership_data_date 的 ownership_distribution.shares 加總;snapshot 存在但 bucket 無對應 level rows 時為 0;整個 current snapshot 缺失時為 null
shares_change integer | null 該 bucket 股數變化,計算方式為 current ownership shares 減去同 ticker 前一個 ownership snapshot 的 shares;current 或 previous snapshot 缺失時為 null;snapshot 存在但 bucket 無對應 level rows 時以 0 計算
ownership_data_date string | null TDCC 持股分布資料日期
ownership_data_age_days number | null ownership 資料相對 chip snapshot 的新鮮度
levels string[] 實際納入計算的 TDCC levels;千張以上為 ["15"],50 張以下為 ["1", ..., "8"]

法人大買 / 法人大賣欄位存放於 daily_chip_market_summary.summary_json。Ownership 排行於讀取 daily chip payload 時從同日 daily_chip_snapshot 的 ownership 比例欄位即時計算排名,並從 ownership_distribution.shares 補上 current_shares / shares_change;若整個 current 或 previous ownership snapshot 缺失,對應股數欄位為 null;若 snapshot 存在但該 bucket 無對應 level rows,股數以 0 計算。若該日 snapshot 沒有 ownership 變動資料,四個排行欄位會回傳空陣列。

31.3 POST /api/chip-recap/{trade_date}/analyze

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

使用 LLM 對指定日期的籌碼面摘要進行分析。結果以 payload hash 快取。

Path Parameters / Query Parameters: 同 31.2

Response: MAS LLM 分析結果(含 requested_trade_date, source_trade_date, is_fallback)。

風險欄位 schema:risks 為結構化陣列(新 client 請使用此欄位),risk_summary 為衍生字串(deprecated,未來主版移除)。Legacy 快取行讀取時會自動補上 risks(單一 EvidenceItem,evidence=legacy=true),保證 response 永遠同時含兩欄。

{
  "headline": "外資主導回補",
  "market_summary": "市場偏多。",
  "group_summary": "半導體流入。",
  "stock_summary": "2330.TW 連買。",
  "etf_summary": "主動式 ETF 00981A 新增緯穎,增碼台積電 20%。被動式 ETF 無顯著指數調整。",
  "risk_summary": "留意槓桿升溫。 / 融資異常擴張。",
  "risks": [
    {"text": "留意槓桿升溫。", "evidence": "margin_change_pct=6.4"},
    {"text": "融資異常擴張。", "evidence": "margin_balance_pct=95"}
  ]
}
欄位 型別 預設值 說明
etf_summary string "" ETF 持股變動分析。主動式 ETF 調倉行為 + 被動式 ETF 指數調整。無 ETF 資料時為空字串。

31.4 GET /api/chip-recap/{trade_date}/groups

取得指定日期各族群的資金流向。新產生的 recap 以 coverage theme membership 作為後端族群來源;歷史資料若尚未重算,可能仍保留舊的 industry / sector / concept / custom 來源。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan
direction string 否 inflow / outflow / neutral / all(預設)
group_type string 否 篩選族群類型
limit int 否 回傳筆數,預設 20,上限 200

Response:

[
  {
    "group_id": "AI伺服器",
    "group_type": "theme",
    "group_name": "AI伺服器",
    "member_count": 120,
    "active_member_count": 95,
    "foreign_net_value": 5678901.0,
    "trust_net_value": -234567.0,
    "dealer_net_value": 123456.0,
    "total_net_value": 5567790.0,
    "margin_change_value": -12345.0,
    "short_change_value": 6789.0,
    "flow_direction": "inflow",
    "flow_score": 85.5,
    "dominant_actor": "foreign",
    "representative_tickers": ["2330.TW", "2303.TW"],
    "meta_json": {}
  }
]

flow_direction 與 flow_score 的語意(issue #1121):

  • flow_direction 為 inflow 需同時滿足 total_net_value > 0 與 flow_score >= mas.chip_recap.inflow_zscore;outflow 需同時滿足 total_net_value < 0 與 flow_score <= mas.chip_recap.outflow_zscore。 只滿足其一 → neutral。
  • flow_score 是當日所有族群 total_net_value 的橫斷面 z-score,已減去 當日平均,因此是相對排名而非絕對方向:全市場普遍賣超的日子,賣超較少的 族群仍可能拿到正的 flow_score。判斷資金方向請看 flow_direction 或 total_net_value 正負,不要單看 flow_score 或用它排序當成流入強度。

31.5 GET /api/chip-recap/{trade_date}/stocks

取得指定日期的個股籌碼事件。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan
event_type string 否 篩選事件類型
limit int 否 回傳筆數,預設 50,上限 200

Response:

[
  {
    "ticker": "2330.TW",
    "event_type": "foreign_surge",
    "event_direction": "inflow",
    "event_score": 92.0,
    "group_id": "semiconductor",
    "payload_json": {}
  }
]

31.6 GET /api/chip-recap/pulse/latest

首頁籌碼面快讀卡片。從既有 daily chip recap 壓縮出三大法人合計買超/合計賣超、主導法人、族群流向、連買連賣與風險提醒。Headline 為規則式產生,不呼叫 LLM。日期規則同 31.1。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 同 31.7,自動查詢最新可用日期。

31.7 GET /api/chip-recap/pulse/{trade_date}

取得指定日期的首頁籌碼面快讀卡片。若該日無資料,自動 fallback 至最近可用日。

Path Parameters / Query Parameters: 同 31.2。

Response:

{
  "pulse_type": "chip",
  "headline": "三大法人合計買超,外資主導,外資、投信買超,自營商賣超,資金流向半導體",
  "headline_parts": {
    "market": "三大法人合計買超",
    "actor": "外資主導",
    "participation": "外資、投信買超,自營商賣超",
    "group": "資金流向半導體"
  },
  "market_bias": "bullish",
  "market_flow": {
    "direction": "net_buy",
    "total_net_value": 18000000000.0,
    "dominant_actor": "foreign",
    "buy_breadth": 612,
    "sell_breadth": 388
  },
  "actor_flows": [
    {"actor": "foreign", "label": "外資", "direction": "buy", "net_value": 15600000000.0, "rank": 1},
    {"actor": "trust", "label": "投信", "direction": "buy", "net_value": 3200000000.0, "rank": 2},
    {"actor": "dealer", "label": "自營商", "direction": "sell", "net_value": -800000000.0, "rank": 3}
  ],
  "group_pulse": {
    "top_inflows": [],
    "top_outflows": []
  },
  "stock_pulse": {
    "continuous_buy": [],
    "continuous_sell": [],
    "sell_to_buy": [],
    "buy_to_sell": []
  },
  "risk_signals": [],
  "data_status": {
    "trade_date": "2026-05-15",
    "requested_trade_date": "2026-05-18",
    "source_trade_date": "2026-05-15",
    "is_fallback": true,
    "market": "taiwan",
    "n_stocks": 1000
  }
}

31A. 個股籌碼面 Summary

單一個股的七維籌碼面總覽(融資/融券/外資/投信/自營/400張大戶/散戶),每維輸出 direction + magnitude + comparison_period 以同一 FlowSignal schema 呈現, 方便前端直接繪製一致的視覺元件。資料來源為 daily_chip_snapshot,read-only。

31A.1 GET /api/chip/summary/{ticker}

Path parameters

參數 類型 必填 說明
ticker string 是 股票代號,例如 2330.TW

Query parameters

參數 類型 必填 說明
trade_date date (ISO) 否 若指定,精確讀取該日且缺值不回退;未指定時,使用該股法人或融資券任一來源已有列的最新日期,所有日維度只讀該日,未到來源為 null

Response

每個 FlowSignal 欄位固定為:

欄位 類型 說明
current number | null 當期數值
previous number | null 前一期數值
change number | null current - previous
change_pct number | null 僅 margin / short 有;其餘為 null
direction "increase" \| "decrease" \| "flat" \| "unknown" 方向判定
magnitude "large" \| "medium" \| "small" \| "none" 相對閾值分級
flow_zscore number | null 原始 clipped ±3 z-score(僅 foreign / trust / dealer 有值;其餘維度為 null)。欄位名對齊上游 daily_chip_snapshot.{actor}_flow_zscore(migration 035)
comparison_period "daily_balance" \| "daily_flow" \| "weekly" 揭露比較週期

各維度語義:

維度 comparison_period current 定義 magnitude 指標
margin / short daily_balance T 日餘額(張) |change_pct|
foreign / trust / dealer daily_flow T 日 net_shares |flow_zscore|(20d)
big_holders_400 weekly TDCC levels 12–15 ratio 加總 |change| (pct 點)
retail weekly 與首頁共用設定;預設 50 張以下、TDCC levels 1–8 ratio 加總 |change| (pct 點)

ownership 週資料不參與日資料日期選擇:未指定 trade_date 時取官方交易日以前最新一期;指定日期時取該日以前最新一期,並以 sources.ownership.data_date 回傳實際觀測日。

Response example

{
  "ticker": "2330.TW",
  "trading_date": "2026-04-16",
  "margin": {
    "current": 26878, "previous": 27298, "change": -420, "change_pct": -1.5386,
    "direction": "decrease", "magnitude": "small", "flow_zscore": null, "comparison_period": "daily_balance"
  },
  "short": {
    "current": 96, "previous": 71, "change": 25, "change_pct": 35.2113,
    "direction": "increase", "magnitude": "large", "flow_zscore": null, "comparison_period": "daily_balance"
  },
  "foreign": {
    "current": -2389835, "previous": 4825960, "change": -7215795, "change_pct": null,
    "direction": "decrease", "magnitude": "small", "flow_zscore": -0.42, "comparison_period": "daily_flow"
  },
  "trust":  { "...": "..." },
  "dealer": { "...": "..." },
  "big_holders_400": {
    "current": 88.22, "previous": 88.4, "change": -0.18, "change_pct": null,
    "direction": "decrease", "magnitude": "small", "flow_zscore": null, "comparison_period": "weekly"
  },
  "retail": { "...": "..." },
  "data_freshness": {
    "ownership_age_days": 6,
    "ownership_data_date": "2026-04-10"
  },
  "meta": { "bias": "bullish", "confidence": 0.75 }
}

Error responses

  • 404 — ticker 無 chip snapshot
  • 422 — trade_date 格式非 ISO

設定來源

參數 來源 預設值
大戶門檻 config/chip_scoring.py::DEFAULT_BIG_HOLDER_THRESHOLD_LOTS 400 (張以上 = levels 12-15)
散戶門檻 config/core.yaml → chip_summary.ownership_change_rankings.retail_threshold_lots 50 (50 張以下 = levels 1–8)
Magnitude 閾值 config/chip_scoring.py::DEFAULT_MAGNITUDE_THRESHOLDS 見檔案

散戶定義統一(#1748)

個股摘要、首頁「散戶增加/減少 Top 10」與 SES small_holder_crowding 共用上述 散戶門檻及 TDCC 級距解析器。預設 50 張涵蓋第 1–8 級(第 8 級至 50,000 股, 第 9 級從 50,001 股起、不納入)。修改設定後須重啟相關程序才會重新載入。 設定缺失時採共用預設 50 張;設定解析失敗或非正門檻會報錯,不以空級距掩蓋錯誤。

這是既有 retail 欄位的語意調整:原先 10 張、第 1–3 級改為首頁設定, current、previous、change 及衍生的 direction、magnitude 可能改變。 欄位名稱、JSON 結構、型別、null 行為、weekly 週期及其餘六個維度均保持原契約; 不需重算 DB,查詢時即依相同的週快照聚合。全部級距為 null 時仍回 null/unknown, 部分級距為 null 時仍依既有摘要契約加總已知值。

以下取自本機 API/DB 於 2026-09-28 擷取的真實資料,交易日與股權快照日均為 2026-09-24,使用同一快照比較修改前後(持股為 %,變化為百分點):

股票 原 10 張持股 新 50 張持股 原週變化 新週變化
2330.TW 5.86 8.78 -0.04 -0.05
2317.TW 13.03 22.53 -0.06 -0.08
2603.TW 11.80 17.47 -0.08 -0.10

原始資料與 API 回應保留於 tests/fixtures/chip_summary_retail_1748.json,回歸測試 逐欄驗證新母體與非散戶欄位保持一致。前端既有「散戶」標籤可繼續直接呈現此回應; 如外部 caller 曾標記「10 張以下」,須同步改為首頁設定定義,避免再次硬編碼門檻。


32. 技術面 Recap (Technical Recap)

每日技術面市場總覽與族群強弱。Pipeline 每日 15:30 後產出。

32.1 GET /api/technical-recap/latest

取得最新一日的技術面市場摘要。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 同 32.2,自動查詢最新可用日期。

32.2 GET /api/technical-recap/{trade_date}

取得指定日期的技術面市場摘要。若該日無資料,自動 fallback 至最近可用日。

Path Parameters:

參數 類型 說明
trade_date date (YYYY-MM-DD) 交易日

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response:

{
  "trade_date": "2026-04-07",
  "requested_trade_date": "2026-04-07",
  "source_trade_date": "2026-04-07",
  "is_fallback": false,
  "market": "taiwan",
  "n_stocks": 1850,
  "advancers": 920,
  "decliners": 780,
  "breakout_count": 45,
  "breakdown_count": 32
}

summary_json.market_context(大盤 ^TWII 技術狀態,additive)新增欄位:

欄位 類型 說明
above_sma_120 bool 收盤 > 120 日均線
above_sma_240 bool 收盤 > 240 日均線
distance_to_sma_120_pct number | null 收盤與 120 日均線乖離百分比
distance_to_sma_240_pct number | null 收盤與 240 日均線乖離百分比

32.3 POST /api/technical-recap/{trade_date}/analyze

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

使用 LLM 對指定日期的技術面摘要進行分析。結果以 payload hash 快取。

Path Parameters / Query Parameters: 同 32.2

Response: MAS LLM 分析結果(含 requested_trade_date, source_trade_date, is_fallback)。

風險欄位 schema:同 31.3 — risks 為結構化陣列(新 client 請使用此欄位),risk_summary 為衍生字串(deprecated,未來主版移除)。

{
  "headline": "半導體推升收紅",
  "market_summary": "技術偏多延續。",
  "breadth_summary": "廣度支撐突破。",
  "group_summary": "半導體、金融領漲。",
  "stock_summary": "2330.TW 突破 20 日。",
  "risk_summary": "廣度偏窄,留意追高風險。",
  "risks": [
    {"text": "廣度偏窄,留意追高風險。", "evidence": "pct_above_sma_20=42; breakout_count=8"}
  ]
}

32.4 GET /api/technical-recap/{trade_date}/groups

取得指定日期各族群的技術面強弱。新產生的 recap 以 coverage theme membership 作為後端族群來源;歷史資料若尚未重算,可能仍保留舊的 industry / sector / concept / custom 來源。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan
trend_state string 否 篩選趨勢狀態(如 uptrend, rebound, breakout 等)
bucket string 否 strong / weak / improving / deteriorating / all(預設)
limit int 否 回傳筆數,預設 20,上限 200

Response:

[
  {
    "group_id": "矽光子",
    "group_type": "theme",
    "group_name": "矽光子",
    "member_count": 120,
    "active_member_count": 95,
    "strength_score": 72.5,
    "trend_state": "uptrend",
    "pct_above_sma_20": 0.68,
    "pct_above_sma_60": 0.55,
    "avg_rsi": 58.3,
    "breakout_count": 8,
    "breakdown_count": 2,
    "representative_tickers": ["2330.TW", "2303.TW"],
    "meta_json": {}
  }
]

32.5 GET /api/technical-recap/{trade_date}/stocks

取得指定日期的個股技術事件。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan
event_type string 否 篩選事件類型
limit int 否 回傳筆數,預設 50,上限 200

Response:

[
  {
    "ticker": "2330.TW",
    "event_type": "breakout",
    "event_direction": "bullish",
    "event_score": 88.0,
    "group_id": "semiconductor",
    "payload_json": {}
  }
]

32.6 GET /api/technical-recap/pulse/latest

首頁技術面快讀卡片。從既有 daily technical recap 壓縮出市場強弱、廣度、突破/跌破、強弱族群與風險提醒。Headline 為規則式產生,不呼叫 LLM。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 同 32.7,自動查詢最新可用日期。

32.7 GET /api/technical-recap/pulse/{trade_date}

取得指定日期的首頁技術面快讀卡片。若該日無資料,自動 fallback 至最近可用日。

Path Parameters / Query Parameters: 同 32.2。

Response:

{
  "pulse_type": "technical",
  "headline": "技術面偏強,上漲家數占優,突破家數較多,半導體族群領漲",
  "headline_parts": {
    "market": "技術面偏強",
    "breadth": "上漲家數占優",
    "breakout": "突破家數較多",
    "group": "半導體族群領漲"
  },
  "market_bias": "bullish",
  "market_breadth": {
    "advancers": 612,
    "decliners": 388,
    "breadth_direction": "positive"
  },
  "breakout_pulse": {
    "breakout_count": 42,
    "breakdown_count": 12,
    "top_breakouts": [],
    "top_breakdowns": []
  },
  "group_pulse": {
    "strong_groups": [],
    "weak_groups": []
  },
  "risk_signals": [
    {
      "signal_type": "high_rsi_overheat",
      "scope": "stock",
      "subject": "5701.TWO",
      "subject_name": "凱崴",
      "severity": "medium",
      "message": "突破同時 RSI 偏高,短線過熱需留意。"
    }
  ],
  "data_status": {
    "trade_date": "2026-05-15",
    "requested_trade_date": "2026-05-18",
    "source_trade_date": "2026-05-15",
    "is_fallback": true,
    "market": "taiwan",
    "n_stocks": 1000
  }
}

risk_signals[] 欄位說明:

欄位 類型 說明
signal_type string 風險類型 ID(如 high_rsi_overheat、failed_breakout_risk、index_up_breadth_weak)
scope string stock / group / market
subject string 對應的 ticker(stock)、族群名(group)或指數代碼(market)
subject_name string 對應的顯示名稱:個股為中文名(如「凱崴」),無對照時 fallback 為 subject
severity string low / medium / high
message string 風險敘述。不含 subject 前綴,前端可獨立用 subject_name 渲染 Link

回溯相容: 舊持久化資料可能仍以 ticker 開頭的訊息儲存且缺少 subject_name,pulse 端會自動補欄並去除前綴,前端可一律依新 schema 解析。

32.8 GET /api/technical-recap/range

多日廣度(breadth)/ 大盤 market_context 精簡時間序列,供前端畫「寬度疊大盤」sparkline。每日僅回傳精簡欄位(非整包 summary_json),以控制 payload 大小。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan
start_date date (YYYY-MM-DD) 否 起始日期,預設為 end_date 往前 default_lookback_days(預設 90 天,可透過 recap.technical.range.default_lookback_days 調整)
end_date date (YYYY-MM-DD) 否 結束日期,預設今日

錯誤:

情境 HTTP Status 說明
start_date 晚於 end_date 422 start_date must be on or before end_date.
區間超過上限 422 超過 max_range_days(預設 365 天,可透過 recap.technical.range.max_range_days 調整):Date range exceeds maximum of {max_range_days} days.
區間內無資料 200 days 回傳空陣列(非 404)

Response:

{
  "market": "taiwan",
  "start_date": "2026-07-01",
  "end_date": "2026-07-28",
  "days": [
    {
      "trade_date": "2026-07-28",
      "n_stocks": 2344,
      "advancers": 225,
      "decliners": 2015,
      "breakout_count": 18,
      "breakdown_count": 756,
      "unchanged": 104,
      "new_highs_20d": 18,
      "new_lows_20d": 756,
      "pct_above_sma_20": 15.61,
      "pct_above_sma_60": 22.78,
      "pct_rsi_above_50": 15.91,
      "breadth_state": "broad_weakness",
      "index_name": "^TWII",
      "index_close": 41603.36,
      "index_change_pct": -4.65,
      "market_regime": "risk_off"
    }
  ]
}

days[] 欄位說明:

欄位 類型 說明
trade_date date 交易日
n_stocks int 個股數
advancers / decliners int 上漲/下跌家數
breakout_count / breakdown_count int 突破/跌破家數
unchanged int | null 平盤家數(取自 breadth_summary.unchanged)
new_highs_20d / new_lows_20d int | null 20 日新高/新低家數
pct_above_sma_20 / pct_above_sma_60 number | null 站上 20/60 日均線家數占比
pct_rsi_above_50 number | null RSI ≥ 50 家數占比
breadth_state string | null 廣度狀態
index_name string | null 基準指數名(取自 market_context.index_name;歷史列可能非 ^TWII,如 2026-06-12 前為 0050.TW,混用會跨尺度)
index_close number | null 大盤收盤(取自 market_context.close)
index_change_pct number | null 大盤漲跌幅(取自 market_context.change_pct)
market_regime string | null 大盤 regime

32.9 GET /api/technical-recap/{trade_date}/breadth/tickers

取得市場寬度卡片上某個數字背後的構成個股,讓畫面上每個數字都能展開查證。

母體: 使用 recap 自己的母體(stocks.market + is_active),不是 /api/signals/market-pulse/tickers 的(data_quality='complete' + ticker 後綴)。2026-08-04 實測兩者為 2,345 vs 2,318 列,同一條件會得到不同答案(new_highs_20d 58 vs 53),所以名單與數字必須共用母體,故另立端點。

Query Parameters:

參數 類型 必填 說明
metric string 是 寬度指標,見下表;未知值回 422
market string 否 市場,預設 taiwan
limit int 否 回傳筆數,預設 50,上限 200(total 不受 limit 影響)

metric 可用值: advancers、decliners、unchanged、new_highs_20d、new_lows_20d、breakout_count、breakdown_count、pct_above_sma_20、pct_above_sma_60、pct_rsi_above_50

Response:

{
  "trade_date": "2026-08-04",
  "market": "taiwan",
  "metric": "new_highs_20d",
  "total": 57,
  "items": [
    {
      "ticker": "2059.TW",
      "name": "川湖",
      "close": 9495.0,
      "change_percent": 9.9595,
      "rsi_value": 66.9156,
      "volume_ratio": 1.276684
    }
  ]
}

total 是套用 limit 前的總數,items 依當日漲跌幅絕對值排序。呼叫端若同時顯示日報快照的數字,兩者可能不同:日報是產出當時的統計,此端點為即時查詢。

32.10 GET /api/technical-recap/{trade_date}/events/summary

取得當日各技術事件類型的個股檔數,供前端畫「今天哪類事件在主導」的分布圖。

為什麼不能用日報的 summary_json.stock_event_summary: 那份 payload 每個事件類型只留 recap.technical.top_event_size(預設 10)檔,用途是餵 LLM 寫日報。數它的長度等於把上限當統計值 —— 2026-08-05 實測 breakout 實際 91 檔、gap_up_breakout 超過 1,100 檔,兩者在該 payload 裡都是 10。

也不能用 /{trade_date}/stocks 自己數: 該端點 limit 上限為 200,2026-08-05 有五個事件類型打滿,真值無法從該端點取得。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

無 limit 參數:事件類型共 18 種(見 _bucket_events),結果集由值域本身封頂,加上限反而會漏掉尾端類型。

Response:

{
  "trade_date": "2026-08-05",
  "market": "taiwan",
  "total_events": 4340,
  "items": [
    { "event_type": "bullish_reversal_pattern", "count": 1203 },
    { "event_type": "breakout", "count": 91 },
    { "event_type": "breakdown", "count": 18 }
  ]
}
欄位 類型 說明
total_events int 各類型 count 加總,即相異的(個股, 事件類型)配對數。不是個股數 —— 一檔同時觸發五種事件會被計五次。2026-08-05 實測加總 4,340,但當日有事件的相異個股只有 2,191 檔,兩者差近一倍,因此欄名帶單位以免被當成「N 檔」渲染
items[].event_type string 事件類型
items[].count int 該類型當日個股檔數(COUNT(DISTINCT ticker);此表無 (trade_date, market, ticker, event_type) 唯一鍵,用 COUNT(*) 只是碰巧成立)

items 依 count 由大到小排序(同數則依 event_type),排序在後端做,避免各前端各自實作而不一致。

當日沒有任何個股觸發的事件類型不會出現在 items —— 回傳長度為當日實際有事件的類型數(2026-08-05 為 17,非 18),呼叫端不可假設固定 18 筆。當日完全無事件(如休市)回 200 且 items 為空陣列、total_events 為 0。


32A. 統一 Recaps(Unified Recaps)

認證: 無需認證(與現有 daily recap endpoint 一致)

📱 前端 / APP 整合: 另見 Recap API 前端整合指南(含 TypeScript / Swift / Kotlin code sample、UI/UX 建議、錯誤處理、空狀態設計)

統一入口,覆蓋三個 dimension × 三個 period_type 共九種組合:

dimension period_type 資料來源
technical daily daily_technical_market_summary(支援 fallback)
technical weekly / monthly periodic_technical_market_summary(精確匹配)
chip daily daily_chip_market_summary(支援 fallback)
chip weekly / monthly periodic_chip_market_summary(精確匹配)
orchestrator daily / weekly / monthly orchestrator_recap

既有 /api/technical-recap/* 與 /api/chip-recap/* 路由保留(backward-compat),內部讀取相同的 daily 資料表。下一個主要版本會 deprecate。

GET /api/recaps/{dimension}/{period_type}/latest

回傳該 dimension + period_type 最新一筆 recap 資料。Daily 對應 MAX(trade_date)(chip daily 的日期規則同 31.1);weekly/monthly/orchestrator 對應 MAX(period_end_date)(後者並以 period_type 篩選)。

Path Parameters: | 參數 | 類型 | 說明 | |------|------|------| | dimension | "technical" \| "chip" \| "orchestrator" | 維度 | | period_type | "daily" \| "weekly" \| "monthly" | 時間粒度 |

Query Parameters: | 參數 | 類型 | 必填 | 預設 | 說明 | |------|------|------|------|------| | market | string | 否 | taiwan | 市場代碼 |

Response: 同 GET /api/recaps/{dimension}/{period_type}/{requested_date},see below。

Errors: - 400 unsupported dimension / period_type - 404 該 dimension + period_type 尚無任何資料

GET /api/recaps/{dimension}/{period_type}/{requested_date}

回傳指定日期的 recap。

  • Daily 路徑:若 requested_date 當日無資料(非交易日或 pipeline 尚未產出),自動 fallback 到 ≤ requested_date 的最新 trade_date,並在 response 中標示 is_fallback=true。
  • Weekly/Monthly 路徑:requested_date 必須精確等於 period_end_date;不 fallback(週期是離散的),找不到回 404。
  • Orchestrator 路徑:requested_date 必須精確等於該 period_type 的 period_end_date。

Path Parameters: | 參數 | 類型 | 說明 | |------|------|------| | dimension | "technical" \| "chip" \| "orchestrator" | 維度 | | period_type | "daily" \| "weekly" \| "monthly" | 時間粒度 | | requested_date | YYYY-MM-DD | 查詢日期(daily)或 period_end_date(weekly/monthly/orchestrator) |

Query Parameters: | 參數 | 類型 | 必填 | 預設 | 說明 | |------|------|------|------|------| | market | string | 否 | taiwan | 市場代碼 |

Response (technical / chip): 對應 market summary 欄位加上:

{
  "period_type": "daily|weekly|monthly",
  "market": "taiwan",
  "period_start_date": "2025-11-17",        // periodic only
  "period_end_date": "2025-11-21",          // periodic only
  "trade_date": "2025-11-21",               // daily only
  "requested_trade_date": "2025-11-25",     // daily only
  "source_trade_date": "2025-11-21",        // daily only
  "is_fallback": true,                      // daily only
  "n_sessions": 5,                          // periodic only
  "n_stocks": 1000,
  "advancers": 3000,
  "decliners": 1500,
  "breakout_count": 40,
  "breakdown_count": 15,
  "summary_json": { ... }
}

dimension=chip&period_type=daily 的 summary_json 另包含前端可直接渲染的法人買賣超個股清單:

欄位 類型 說明
summary_json.top_bullish_stocks InstitutionalStockFlowItem[] 當日三大法人合計淨買超前 10 大股票,僅包含 total_net_value > 0,依金額由大到小排序;金額相同時依 ticker 穩定排序
summary_json.top_bearish_stocks InstitutionalStockFlowItem[] 當日三大法人合計淨賣超前 10 大股票,僅包含 total_net_value < 0,依金額由小到大排序;金額相同時依 ticker 穩定排序

InstitutionalStockFlowItem 欄位說明請參考上方 GET /api/chip-recap/{trade_date} 章節的表格。

dimension=chip&period_type=daily 另在 top-level additive 回傳 ownership 排行欄位;既有 summary_json 不會被攤平。每個 OwnershipChangeRankingItem 也包含 current_shares 與 shares_change;snapshot 存在但 bucket 無對應 level rows 時以 0 計算,current 或 previous snapshot 缺失時為 null:

欄位 類型 說明
top_big_holder_1000_increases OwnershipChangeRankingItem[] 千張以上大戶(TDCC level 15)持股比例增加前 10 名;僅包含 change > 0,依 change DESC, ticker ASC 排序
top_big_holder_1000_decreases OwnershipChangeRankingItem[] 千張以上大戶(TDCC level 15)持股比例減少前 10 名;僅包含 change < 0,依 change ASC, ticker ASC 排序
top_retail_50_increases OwnershipChangeRankingItem[] 50 張以下散戶(TDCC levels 1-8)持股比例增加前 10 名;僅包含 change > 0,依 change DESC, ticker ASC 排序
top_retail_50_decreases OwnershipChangeRankingItem[] 50 張以下散戶(TDCC levels 1-8)持股比例減少前 10 名;僅包含 change < 0,依 change ASC, ticker ASC 排序

reasons 由後端產生,例如 foreign_net_buy, trust_continuous_buy, foreign_buy_to_sell,前端不需要從 stock_event_summary 自行合併推論。

Response (orchestrator): 頂層欄位為最常用的 synthesis / narrative 投影,原始 JSONB 仍可從 synthesis_json / narrative_json 取得:

{
  "period_type": "weekly",
  "period_start_date": "2025-11-17",
  "period_end_date": "2025-11-21",
  "market": "taiwan",
  "headline": "本週 AI 擴散",
  "market_synthesis": "技術突破搭配法人買超。",
  "divergence_narrative": "本週未見顯著背離。",
  "dual_confirmation_narrative": "本週技術面與籌碼面同向確認的強勢個股:\n- 台積電 (2330)(技術面 breakout|籌碼面 continuous_buy)",
  "dual_confirmation_stocks": [ { "ticker": "2330", "direction": "bullish", ... } ],
  "dual_confirmation_groups": [],
  "divergence_stocks": [],
  "risks": [ { "text": "...", "evidence": "..." } ],
  "parser_warnings": [],
  "synthesis_json": { ... },
  "narrative_json": { "dual_confirmation_narrative": "..." },
  "technical_recap_json": { ... },
  "chip_recap_json": { ... }
}

dual_confirmation_* 為程式模板輸出,逐一點名同向訊號個股/族群,無 LLM 幻覺風險。headline / market_synthesis / divergence_narrative / risks 由 LLM 生成;失敗或解析不成功時 degrade 為空字串/空陣列並記錄於 parser_warnings。

Daily technical recap technical_recap_json.stock_events[*].payload_json 會帶 structured_signals 與 ta_index_groups。Event buckets 也可包含 ma_month_reclaim / ma_month_breakdown / gap_up_breakout / gap_down_breakdown / limit_up_strength / limit_down_risk / bullish_reversal_pattern / bearish_reversal_pattern。Weekly/monthly periodic technical event payload 另提供 period_ma_events,用於辨識該週/月 K 的 MA20/MA60 站上或跌破。

Errors: - 400 unsupported dimension / period_type - 404 找不到對應 row(weekly/monthly/orchestrator)或 daily 無可 fallback 的資料

POST /api/recaps/{period_type}/{requested_date}/analyze ⭐ 單次呼叫取得三種 LLM 敘事

🔒 Auth: 需 admin 權限(Authorization: Bearer <access_token>)。非 admin 回 403,無效 token 回 401;未帶 Authorization header 回 422。

一次回傳 technical + chip + orchestrator 三個 dimension 的 LLM 敘事,覆蓋 daily / weekly / monthly 三種週期。前端不需要再做三個串接呼叫 + orchestrator GET 拼裝。

Path Parameters: | 參數 | 類型 | 說明 | |------|------|------| | period_type | "daily" \| "weekly" \| "monthly" | 時間粒度 | | requested_date | YYYY-MM-DD | Daily 為查詢日期;weekly/monthly 為 period_end_date |

Query Parameters: | 參數 | 類型 | 必填 | 預設 | 說明 | |------|------|------|------|------| | market | string | 否 | taiwan | 市場代碼 | | response_mode | "json" \| "stream" | 否 | json | stream 改為 SSE 逐維度串流,僅支援 period_type=daily(見下方「SSE 串流模式」) |

Response (200):

{
  "period_type": "weekly",
  "period_start_date": "2025-11-17",
  "period_end_date": "2025-11-21",
  "market": "taiwan",
  "requested_date": "2025-11-21",
  "source_date": "2025-11-21",
  "is_fallback": false,
  "technical": {
    "trade_date": "2025-11-21",
    "headline": "台股技術結構多空分歧...",
    "market_summary": "...",
    "breadth_summary": "...",
    "group_summary": "...",
    "stock_summary": "...",
    "risks": [{"text": "...", "evidence": "..."}],
    "risk_summary": "...",
    "period_type": "weekly",
    "period_start_date": "2025-11-17",
    "period_end_date": "2025-11-21",
    "period_summary": "..."
  },
  "technical_error": null,
  "chip": { /* 同 technical 結構,但 chip 面向;若該 domain LLM 失敗則為 null */ },
  "chip_error": null,
  "orchestrator": {
    "period_type": "weekly",
    "period_start_date": "2025-11-17",
    "period_end_date": "2025-11-21",
    "market": "taiwan",
    "headline": "...",
    "market_synthesis": "...",
    "dual_confirmation_narrative": "本週技術面與籌碼面同向確認的強勢個股:...",
    "divergence_narrative": "...",
    "dual_confirmation_stocks": [
      {"ticker": "2330.TW", "direction": "bullish", "technical_event_type": "breakout", "chip_event_type": "continuous_buy", "combined_score": 1.95, /* ... */}
    ],
    "dual_confirmation_groups": [ /* ... */ ],
    "divergence_stocks": [ /* ... */ ],
    "risks": [ /* ... */ ],
    "parser_warnings": []
  },
  "orchestrator_error": null
}

Domain / Orchestrator isolation: 若 technical 或 chip 單一 domain LLM 失敗(例如 rate-limit),endpoint 會回傳 200 partial response:成功的 domain 正常填充,失敗的 domain 為 null,並以 technical_error / chip_error 帶公開分類錯誤(例如 rate limit exceeded、request timed out、analysis failed);若兩個 domain 都失敗則回傳 503。若 orchestrator 階段失敗(LLM 超時、rate-limit 等),endpoint 仍回傳 200,technical + chip 欄位正常填充,orchestrator 為 null 或帶 parser warning 的 deterministic 結果,且 orchestrator_error / orchestrator.parser_warnings 帶錯誤訊息 — 前端可選擇 degrade 只顯示可用面向。原始 exception 只寫入 server log,不回傳給 client。

Side effect: Orchestrator 成功時會 UPSERT 到 orchestrator_recap table — 相當於 on-demand backfill。同一 period 之後呼叫 GET /api/recaps/orchestrator/{period_type}/{date} 可直接取得結果。

Cache: Technical / chip 走 recap_analysis_cache(同日同 period 第二次呼叫 ~150ms)。Orchestrator 每次重新 synthesize(結果 UPSERT 覆寫 — 最後一次 wins)。

Errors: - 400 unsupported period_type / response_mode,或 response_mode=stream 搭配非 daily period - 409 weekly/monthly 該期尚未 aggregated — 請先跑 scripts/run_periodic_recap.py --period-type {weekly|monthly} --period-end-date {date} - 503 MAS service 尚未初始化,或 technical + chip 兩個 domain LLM 都失敗

Example:

curl -X POST "http://localhost:8000/api/recaps/daily/2026-04-22/analyze?market=taiwan"
curl -X POST "http://localhost:8000/api/recaps/weekly/2025-11-21/analyze"

SSE 串流模式(response_mode=stream,daily only)

JSON 模式一次跑完三段 LLM 才回應,實測 40-60 秒(真實環境量測:technical 0.7s → chip 24.6s → orchestrator 47.6s),超過一般 client 的 request timeout。串流模式讓每個 dimension 算完就送出,前端可逐張卡片渲染。

Framing 與心跳與 /api/v1/mas/analyze 共用同一支 api/sse_helpers.py:事件為 event: msg\ndata: {json},靜默期每 sse_heartbeat_interval_seconds(config/core.yaml,目前 15s)送出一則 : keep-alive comment。Response headers 帶 Cache-Control: no-cache, no-transform 與 X-Accel-Buffering: no,避免中間 proxy 緩衝整條串流。

事件 envelope:

{
  "v": 1,
  "seq": 4,
  "request_id": "req_ab12cd34ef56",
  "period_type": "daily",
  "market": "taiwan",
  "ts": "2026-07-29T02:14:12+00:00",
  "dimension": "technical",
  "type": "result",
  "message": "",
  "payload": { }
}

type dimension payload 說明
meta system {period_start_date, period_end_date, requested_date, source_date, is_fallback} 第一則事件,供前端顯示資料日期/fallback 提示
status technical / chip / orchestrator {} 該維度開始分析
result technical / chip / orchestrator 該維度完整結構化結果(等同 JSON 模式的 .technical / .chip / .orchestrator) 算完即送
error technical / chip / orchestrator {} message 為公開分類錯誤,與 JSON 模式的 *_error 相同字串
error system {} technical + chip 皆失敗
done system {} 串流結束

seq 由 1 開始嚴格遞增,request_id 全串流一致。

與 JSON 模式的行為差異(唯一一項): technical + chip 皆失敗時,JSON 模式回 503;串流模式此時 header 已送出,改以 system / error 事件表達,HTTP status 仍是 200。所有開串流之前的失敗(400 / 409 / 503 MAS service 未初始化 / 503 orchestrator LLM unavailable)都維持一般 HTTP 回應。

Example:

curl -N -X POST "http://localhost:8000/api/recaps/daily/2026-07-28/analyze?response_mode=stream&market=taiwan" \
  -H "Authorization: Bearer <access_token>"


32B. 首頁 Market Pulse

首頁聚合快讀 API。一次回傳技術面 Pulse 與籌碼面 Pulse,讓首頁不用分別解析完整 recap response。所有 headline 均為規則式產生,不呼叫 LLM。

32B.1 GET /api/market-pulse/latest

取得最新首頁市場快讀。技術面與籌碼面各自獨立解析最新可用交易日:技術面採 daily_technical_market_summary 最新日期,籌碼面日期規則同 31.1,兩者不再要求相同,回應可能混合兩個不同資料日期,每個區塊各自標示所屬日期。data_status 新增 technical_trade_date/chip_trade_date(ISO date 或 null)與 technical_is_fallback/chip_is_fallback;相容欄位 data_status.trade_date/data_status.source_trade_date 回傳兩側日期中較新者,data_status.is_fallback 則在任一側 fallback 時為 true。若某一側完全無資料,該側 pulse 區塊回 no_data(日期為 null),另一側仍正常回傳(HTTP 200);僅當兩側皆無資料時才回 404。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 同 32B.2,自動查詢最新可用日期。

32B.2 GET /api/market-pulse/{trade_date}

取得指定日期首頁市場快讀。技術面與籌碼面各自獨立解析 <= trade_date 的最新可用日:技術面找 daily_technical_market_summary 中 <= trade_date 的最新日期,籌碼面找 daily_chip_market_summary 中 <= trade_date 的最新日期,兩側各自 fallback、互不影響,並分別以 data_status.technical_is_fallback/data_status.chip_is_fallback 標示。前端應顯示 data_status.technical_trade_date/data_status.chip_trade_date;相容欄位 data_status.trade_date/data_status.source_trade_date 回傳兩側日期中較新者,data_status.is_fallback 在任一側回補時為 true。若某一側在 <= trade_date 範圍內完全無資料,該側 pulse 區塊回 no_data(日期為 null),另一側仍正常回傳(HTTP 200);僅當兩側皆無資料時才回 404。

Path Parameters:

參數 類型 說明
trade_date date (YYYY-MM-DD) 交易日

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response:

{
  "headline": "技術面偏強,籌碼面三大法人合計買超,外資、投信買超,自營商賣超",
  "headline_parts": {
    "technical": "技術面偏強",
    "chip": "籌碼面三大法人合計買超,外資、投信買超,自營商賣超"
  },
  "market_bias": "bullish",
  "technical_pulse": {
    "pulse_type": "technical",
    "headline": "技術面偏強,上漲家數占優,突破家數較多,半導體族群領漲",
    "data_status": {
      "trade_date": "2026-05-18",
      "requested_trade_date": "2026-05-18",
      "source_trade_date": "2026-05-18",
      "is_fallback": false,
      "market": "taiwan"
    }
  },
  "chip_pulse": {
    "pulse_type": "chip",
    "headline": "三大法人合計買超,外資主導,外資、投信買超,自營商賣超,資金流向半導體",
    "data_status": {
      "trade_date": "2026-05-15",
      "requested_trade_date": "2026-05-18",
      "source_trade_date": "2026-05-15",
      "is_fallback": true,
      "market": "taiwan"
    }
  },
  "data_status": {
    "trade_date": "2026-05-18",
    "requested_trade_date": "2026-05-18",
    "source_trade_date": "2026-05-18",
    "is_fallback": true,
    "technical_trade_date": "2026-05-18",
    "technical_is_fallback": false,
    "chip_trade_date": "2026-05-15",
    "chip_is_fallback": true,
    "market": "taiwan"
  }
}

technical_pulse.data_status 與 chip_pulse.data_status 各自帶該側自己的 trade_date/is_fallback(此例中籌碼面資料入庫較晚、回補至 2026-05-15,技術面則有當日資料);top-level data_status 再彙總出 technical_trade_date/chip_trade_date 供首頁一次讀取兩側日期,trade_date/source_trade_date 為兩者中較新的 2026-05-18,is_fallback 因籌碼面回補而為 true。


32C. 統一 Daily Market

每日大盤 dashboard API。一次回傳 TAIEX 指數、官方大盤成交統計、TWSE 漲跌家數、技術 breadth、三大法人買賣超、大盤融資融券、外資台指期未平倉、market regime 與 leadership 摘要。

資料來源規則:

  • TAIEX / 成交量 / 成交值 / 漲跌家數:TWSE primary,FinMind 僅作已驗證欄位 fallback。
  • 大盤融資、融券、借券賣出、外資台指期:讀既有官方 market-chip context。
  • margin.twse_listed_maintenance_ratio(上市融資維持率,百分比):value = Σ(上市個股融資張數 × 收盤價 × 1000) ÷ official_market_margin_balance_amount × 100,分子由 margin_balance(source_market='TWSE')JOIN stock_ohlc 即時聚合,分母為官方上市融資金額。scope 僅含上市(TWSE),不含上櫃(TPEx),因官方融資金額來源(FinMind)僅涵蓋上市;勿與坊間含上櫃的「大盤融資維持率」混用。自帶 status(available/pending_source/no_data),非台股市場回 no_data。並含 previous_day_change 子物件(previous_value、value_change=與前一可用交易日的百分點差(pp)、direction、previous_date、status);今日維持率不可用 / 無前一可用日 / gap>10 日 / 前日不可算時回 no_data。
  • 大盤均線、關鍵均線狀態與量能結構:讀 daily_technical_snapshot 的 ^TWII benchmark row;缺資料時回 no_data,不得 fallback ETF proxy。
  • 大盤前日差異:讀 market_daily_metrics 前一個可用交易日的官方指數收盤價,回傳 direction=up|down|flat。
  • 大盤成交值前日差異:讀 market_daily_metrics 前一個可用交易日的官方成交值,回傳 turnover.previous_day_change;不得用個股成交值或 volume proxy 自算。
  • 三大法人連續買賣超:讀 daily_chip_market_summary 往前計算同方向 streak,不從個股籌碼資料聚合。
  • 外資期貨部位變化:讀 market_chip_metrics 前一個可用市場層級期貨資料日。
  • 前一日/前一筆變化若資料日期間隔超過 10 個 calendar days,視為 stale gap,change 欄位回 null 並標 status: "no_data"。
  • 欄位無官方/市場層級資料時回 null,並以 status 標示 pending_source / no_data / source_error;不得使用個股 rows、ETF proxy 或 stock_ohlc 的 0 volume 自算成官方資料。

32C.1 GET /api/daily-market/latest

取得最新 unified daily market payload。技術面與籌碼面各自獨立解析最新可用交易日:技術面採 daily_technical_market_summary 最新日期,籌碼面日期規則同 31.1(連買連賣區塊讀同一籌碼日),兩者不再要求相同。回應新增 technical_trade_date/chip_trade_date(ISO date 或 null)與 technical_is_fallback/chip_is_fallback;相容欄位 trade_date/source_trade_date 回傳兩側日期中較新者。本端點是「每一側各自取自己最新的一天」,沒有任何被指定卻無法滿足的日期,故 technical_is_fallback/chip_is_fallback/is_fallback 恆為 false——籌碼面晚幾小時才烘好不算回補(回補只發生在 32C.2 指定日期時)。若某一側完全無資料,該側相關區塊回 no_data(date 為 null),另一側仍正常回傳(HTTP 200);僅當兩側皆無資料時才回 404。

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response: 欄位同 32C.2,日期由系統自動解析。以下為交易日 16:53(技術面烘完)~21:41(籌碼鏈烘完)之間的典型回應節錄——技術面已是當日、籌碼面仍停在前一交易日:

{
  "trade_date": "2026-06-12",
  "requested_trade_date": "2026-06-12",
  "source_trade_date": "2026-06-12",
  "technical_trade_date": "2026-06-12",
  "chip_trade_date": "2026-06-11",
  "is_fallback": false,
  "technical_is_fallback": false,
  "chip_is_fallback": false,
  "market": "taiwan",
  "data_status": {
    "technical_recap": { "status": "available", "date": "2026-06-12" },
    "market_regime": { "status": "available", "date": "2026-06-12" },
    "chip_recap": { "status": "available", "date": "2026-06-11" },
    "institutional_streaks": { "status": "available", "date": "2026-06-11" }
  },
  "index": { "close": 44169.04, "date": "2026-06-12", "status": "available" },
  "institutional": { "date": "2026-06-11", "status": "available" },
  "leadership": {
    "dates": { "technical": "2026-06-12", "chip": "2026-06-11" }
  },
  "risk_signals": [
    { "source": "technical", "date": "2026-06-12", "signal_type": "index_up_breadth_weak" },
    { "source": "chip", "date": "2026-06-11", "signal_type": "margin_surge" }
  ]
}

技術面區塊(index/turnover/market_breadth/technical_breadth/regime)帶 2026-06-12,籌碼面區塊(institutional/margin/short/futures)帶 2026-06-11,data_status 每個鍵各自帶所屬區塊的 date,risk_signals 逐筆以 source 對應的那一側日期標示;相容欄位 trade_date/source_trade_date 取兩者中較新的 2026-06-12。21:41 籌碼鏈跑完後 chip_trade_date 自動跟上為 2026-06-12,全頁回到單一日期。

32C.2 GET /api/daily-market/{trade_date}

取得指定日期 unified daily market payload。技術面與籌碼面各自獨立解析 <= trade_date 的最新可用日:技術面找 daily_technical_market_summary 中 <= trade_date 的最新日期,籌碼面找 daily_chip_market_summary 中 <= trade_date 的最新日期,兩側各自 fallback、互不影響,並分別以 technical_is_fallback/chip_is_fallback 標示。若某一側在 <= trade_date 範圍內完全無資料,該側相關區塊回 no_data(date 為 null),另一側仍正常回傳(HTTP 200);僅當兩側皆無資料時才回 404。

Path Parameters:

參數 類型 說明
trade_date date (YYYY-MM-DD) 查詢日期

Query Parameters:

參數 類型 必填 說明
market string 否 市場,預設 taiwan

Response 200 主要欄位:

欄位 類型 nullable 說明
trade_date string no 相容欄位:technical_trade_date 與 chip_trade_date 兩者中較新的日期
requested_trade_date string no Client requested date
source_trade_date string no 同 trade_date,供前端一致顯示
is_fallback boolean no 任一側(technical 或 chip)fallback 至較早日期時為 true
technical_trade_date string yes 技術面實際採用的交易日;技術面完全無資料時為 null
technical_is_fallback boolean no 技術面是否 fallback 至 <= trade_date 的較早日期
chip_trade_date string yes 籌碼面實際採用的交易日;籌碼面完全無資料時為 null
chip_is_fallback boolean no 籌碼面是否 fallback 至 <= trade_date 的較早日期
index object no TAIEX OHLC/漲跌、5/20/60/120/240 日均線(moving_averages:ma{N}、distance_to_ma{N}_pct、bias_rate_ma{N}_pct,N ∈ 5/20/60/120/240;乖離率 = (收盤 − MAN) ÷ MAN × 100,取同日 ^TWII 技術快照,快照缺或均線暖機中為 null,不以前一日補值)、關鍵均線狀態與前日差異,缺官方資料時數值為 null
turnover object no 官方大盤成交股數、成交金額、成交筆數、成交值前日差異,並附 benchmark 量能結構
market_breadth object no TWSE 整體市場/股票漲跌與漲跌停家數
technical_breadth object no 技術 recap breadth 摘要
institutional object no 官方三大法人買賣超金額與連續買賣超天數
margin object no 官方大盤融資餘額/變動,含 twse_listed_maintenance_ratio(上市融資維持率)子物件
short object no 官方融券與借券賣出餘額
futures object no 外資台指期未平倉多單/空單/淨部位與淨空單變化
regime object no market_regime_daily 趨勢狀態
leadership object no 強弱族群與資金流向摘要;新增 dates: {technical, chip} 分別標示兩側各自採用的交易日
risk_signals array no technical + chip 風險訊號合併陣列,欄位定義同 32.7 risk_signals[],另附 source(technical/chip)與該訊號所屬那一側的 date
data_status object no 各來源可用狀態;每個子欄位由純字串改為物件 {"status": "<available\|pending_source\|no_data\|...>", "date": "<ISO date\|null>"},date 反映該欄位所屬那一側(technical 或 chip)的實際資料日期

Example:

{
  "trade_date": "2026-06-12",
  "requested_trade_date": "2026-06-12",
  "source_trade_date": "2026-06-12",
  "is_fallback": false,
  "technical_trade_date": "2026-06-12",
  "technical_is_fallback": false,
  "chip_trade_date": "2026-06-12",
  "chip_is_fallback": false,
  "market": "taiwan",
  "headline": "技術面偏強,籌碼面三大法人合計買超,外資、投信買超,自營商賣超",
  "market_bias": "bullish",
  "data_status": {
    "technical_recap": { "status": "available", "date": "2026-06-12" },
    "chip_recap": { "status": "available", "date": "2026-06-12" },
    "official_market_daily": { "status": "available", "date": "2026-06-12" },
    "index_moving_averages": { "status": "available", "date": "2026-06-12" },
    "index_previous_day_change": { "status": "available", "date": "2026-06-12" },
    "turnover_previous_day_change": { "status": "available", "date": "2026-06-12" },
    "turnover_volume_structure": { "status": "available", "date": "2026-06-12" },
    "institutional_streaks": { "status": "available", "date": "2026-06-12" },
    "foreign_tx_futures_position_change": { "status": "available", "date": "2026-06-12" },
    "twse_listed_margin_maintenance_ratio": { "status": "available", "date": "2026-06-12" },
    "twse_listed_margin_maintenance_ratio_previous_day_change": { "status": "available", "date": "2026-06-12" },
    "market_regime": { "status": "available", "date": "2026-06-12" }
  },
  "index": {
    "ticker": "^TWII",
    "name": "TAIEX",
    "open": 43587.63,
    "high": 44798.88,
    "low": 43587.63,
    "close": 44169.04,
    "change_points": 1019.58,
    "change_percent": 2.36,
    "source": "TWSE_MI_INDEX_IND",
    "date": "2026-06-12",
    "status": "available",
    "moving_averages": {
      "ma5": 43750.252,
      "ma20": 43568.7185,
      "ma60": 39157.4413,
      "ma120": 35237.3396,
      "ma240": 30255.1302,
      "distance_to_ma5_pct": 0.9572,
      "distance_to_ma20_pct": 1.3779,
      "distance_to_ma60_pct": 12.7986,
      "distance_to_ma120_pct": 25.3473,
      "distance_to_ma240_pct": 45.9886,
      "bias_rate_ma5_pct": 0.9572,
      "bias_rate_ma20_pct": 1.3779,
      "bias_rate_ma60_pct": 12.7986,
      "bias_rate_ma120_pct": 25.3473,
      "bias_rate_ma240_pct": 45.9886,
      "benchmark_ticker": "^TWII",
      "source": "daily_technical_snapshot",
      "date": "2026-06-12",
      "status": "available"
    },
    "key_ma_state": {
      "ma_alignment": "bullish",
      "price_position": "above_all",
      "above_ma5": true,
      "above_ma20": true,
      "above_ma60": true,
      "days_above_ma5": 1,
      "days_above_ma20": 1,
      "days_above_ma60": 50,
      "ma5_break_direction": "break_above",
      "ma20_break_direction": "break_above",
      "ma60_break_direction": "above",
      "benchmark_ticker": "^TWII",
      "source": "daily_technical_snapshot",
      "date": "2026-06-12",
      "status": "available"
    },
    "previous_day_change": {
      "previous_close": 43149.46,
      "close_change": 1019.58,
      "close_change_pct": 2.363,
      "direction": "up",
      "previous_date": "2026-06-11",
      "source": "TWSE_MI_INDEX_IND",
      "date": "2026-06-12",
      "status": "available"
    }
  },
  "turnover": {
    "trade_volume_shares": 12336471343,
    "trade_value_amount": 1169186958350,
    "transaction_count": 5353143,
    "source": "TWSE_MI_INDEX_MS",
    "date": "2026-06-12",
    "status": "available",
    "previous_day_change": {
      "previous_trade_value_amount": 1338516691099,
      "trade_value_change_amount": -169329732749,
      "trade_value_change_pct": -12.6505507085,
      "direction": "down",
      "previous_date": "2026-06-11",
      "source": "TWSE_MI_INDEX_MS",
      "date": "2026-06-12",
      "status": "available"
    },
    "volume_structure": {
      "current_volume": 12336471343,
      "volume_ma_5": 14258199219,
      "avg_volume_20": 15227862957,
      "volume_ratio": 0.810125,
      "volume_status": "normal",
      "benchmark_ticker": "^TWII",
      "source": "daily_technical_snapshot",
      "date": "2026-06-12",
      "status": "available"
    }
  },
  "market_breadth": {
    "market_up_count": 9333,
    "market_down_count": 2154,
    "market_unchanged_count": 399,
    "market_limit_up_count": 454,
    "market_limit_down_count": 121,
    "stock_up_count": 808,
    "stock_down_count": 203,
    "stock_unchanged_count": 57,
    "stock_limit_up_count": 42,
    "stock_limit_down_count": 4,
    "source": "TWSE_MI_INDEX_MS",
    "date": "2026-06-12",
    "status": "available"
  },
  "margin": {
    "official_market_margin_balance_amount": 557027949000,
    "official_market_margin_change_amount": 7850967000,
    "official_market_margin_balance_lots": 9088829,
    "official_market_margin_change_lots": null,
    "source": "FINMIND_TaiwanStockTotalMarginPurchaseShortSale",
    "date": "2026-06-12",
    "status": "available",
    "twse_listed_maintenance_ratio": {
      "label": "上市融資維持率",
      "scope": "twse_listed",
      "value": 193.2,
      "collateral_value": 1076153798610,
      "margin_loan_amount": 557027949000,
      "matched_stocks": 1205,
      "source": "margin_balance×stock_ohlc ÷ official_market_margin_balance_amount",
      "date": "2026-06-12",
      "status": "available",
      "previous_day_change": {
        "previous_value": 188.87,
        "value_change": 4.33,
        "direction": "up",
        "previous_date": "2026-06-11",
        "status": "available"
      }
    }
  },
  "institutional": {
    "foreign_net_amount": 28690268352,
    "trust_net_amount": 14233248854,
    "dealer_net_amount": 8942532337,
    "total_net_amount": 51866049543,
    "source": "TWSE_BFI82U",
    "date": "2026-06-12",
    "status": "available",
    "streaks": {
      "foreign": {
        "direction": "buy",
        "days": 1,
        "latest_net_amount": 28690268352,
        "start_date": "2026-06-12",
        "end_date": "2026-06-12",
        "source": "daily_chip_market_summary",
        "status": "available"
      },
      "trust": {
        "direction": "buy",
        "days": 3,
        "latest_net_amount": 14233248854,
        "start_date": "2026-06-10",
        "end_date": "2026-06-12",
        "source": "daily_chip_market_summary",
        "status": "available"
      },
      "dealer": {
        "direction": "buy",
        "days": 3,
        "latest_net_amount": 8942532337,
        "start_date": "2026-06-10",
        "end_date": "2026-06-12",
        "source": "daily_chip_market_summary",
        "status": "available"
      },
      "total": {
        "direction": "buy",
        "days": 1,
        "latest_net_amount": 51866049543,
        "start_date": "2026-06-12",
        "end_date": "2026-06-12",
        "source": "daily_chip_market_summary",
        "status": "available"
      },
      "source": "daily_chip_market_summary",
      "status": "available"
    }
  },
  "futures": {
    "foreign_tx_futures_long_oi": 9318,
    "foreign_tx_futures_short_oi": 74357,
    "foreign_tx_futures_net_oi": -65039,
    "foreign_tx_futures_net_short_oi": 65039,
    "source": "FINMIND_TaiwanFuturesInstitutionalInvestors",
    "date": "2026-06-12",
    "status": "available",
    "foreign_tx_futures_position_change": {
      "foreign_tx_futures_net_oi_change": -5039,
      "foreign_tx_futures_net_short_oi_change": 5039,
      "previous_foreign_tx_futures_net_oi": -60000,
      "previous_foreign_tx_futures_net_short_oi": 60000,
      "previous_date": "2026-06-11",
      "source": "FINMIND_TaiwanFuturesInstitutionalInvestors",
      "date": "2026-06-12",
      "status": "available"
    }
  }
}

缺資料範例:

{
  "index": {
    "ticker": "^TWII",
    "name": "TAIEX",
    "close": null,
    "source": null,
    "date": null,
    "status": "pending_source"
  },
  "turnover": {
    "trade_value_amount": null,
    "status": "pending_source",
    "previous_day_change": {
      "previous_trade_value_amount": null,
      "trade_value_change_amount": null,
      "trade_value_change_pct": null,
      "direction": null,
      "previous_date": null,
      "source": null,
      "date": null,
      "status": "pending_source"
    }
  }
}

32C.3 GET /api/daily-market/range

取得指定日期區間的大盤每日籌碼指標時間序列(精簡版)。日期列以 market_chip_metrics 與 market_daily_metrics 兩表交易日的聯集為基礎(#1318):籌碼尚未烘焙的日期(融資券約 21:30 公布、21:41 入庫)仍會出列,技術側欄位(大盤成交值、指數收盤、乖離率)先有值、籌碼欄位為 null,與首頁 /latest 的雙側日期行為同步;並排除所有輸出欄位皆為 null 的 tombstone 列。逐交易日回傳:融資餘額、融券餘額、外資台指期多單/空單/淨額、上市融資維持率、大盤成交值與大盤均線乖離率。

維持率分子(融資擔保品現值 = Σ 個股融資張數 × 收盤價 × 1000,僅上市 TWSE)以單一 set-based 聚合查詢一次計算整段,故不設區間上限。

Query Parameters:

參數 類型 必填 說明
start_date date (YYYY-MM-DD) 否 起始日。省略時 = end_date − default_lookback_days(config/core.yaml 的 daily_market.range_query.default_lookback_days,預設 30)
end_date date (YYYY-MM-DD) 否 結束日。省略時 = 今天
market string 否 市場,預設 taiwan

start_date > end_date → 422。

Response 200 欄位:

欄位 類型 nullable 說明
market string no 市場
start_date string no 實際採用的起始日
end_date string no 實際採用的結束日
count integer no daily 筆數
daily array no 每交易日一筆,依日期升冪
daily[].trade_date string no 交易日
daily[].margin_balance_amount integer yes 融資餘額(元)
daily[].short_balance_lots integer yes 融券餘額(張)
daily[].foreign_tx_futures_long_oi integer yes 外資台指期多單(口)
daily[].foreign_tx_futures_short_oi integer yes 外資台指期空單(口)
daily[].foreign_tx_futures_net_oi integer yes 外資台指期淨額(多−空,口)
daily[].twse_listed_maintenance_ratio number yes 上市融資維持率(%)
daily[].market_index_close number yes 大盤指數(TAIEX/^TWII)收盤
daily[].market_trade_value_amount integer yes 大盤成交值(元)
daily[].bias_rate_ma5_pct number yes 大盤收盤相對 MA5 的乖離率(%),來源為 daily_technical_snapshot 的 ^TWII
daily[].bias_rate_ma20_pct number yes 大盤收盤相對 MA20 的乖離率(%),來源為 daily_technical_snapshot 的 ^TWII
daily[].bias_rate_ma60_pct number yes 大盤收盤相對 MA60 的乖離率(%),來源為 daily_technical_snapshot 的 ^TWII
daily[].bias_rate_ma120_pct number yes 大盤收盤相對 MA120(半年線)的乖離率(%),來源為 daily_technical_snapshot 的 ^TWII
daily[].bias_rate_ma240_pct number yes 大盤收盤相對 MA240(年線)的乖離率(%),來源為 daily_technical_snapshot 的 ^TWII

各欄位僅在其來源 status == "available" 時回實值,否則回 null;外資期貨與成交值不放行 derived_available,但大盤指數收盤允許 derived_available(皆與單日端點同日同值);維持率在融資金額缺、擔保品現值為 0、或無對應個股時回 null;乖離率 = (收盤 − MAN) ÷ MAN × 100(四位小數,收盤為同日 ^TWII 快照收盤;2018-07-24~2020-01-14 間有 53 天快照收盤與 market_index_close 不同或後者為 null,勿以 market_index_close 重算),在同日 ^TWII 技術快照缺資料、該均線仍在暖機期(MA120 需 120 根、MA240 需 240 根 K 棒)或 MAN = 0 時回 null,不以前一日均線補值。

Example: GET /api/daily-market/range?start_date=2026-06-11&end_date=2026-06-12

{
  "market": "taiwan",
  "start_date": "2026-06-11",
  "end_date": "2026-06-12",
  "count": 2,
  "daily": [
    {
      "trade_date": "2026-06-11",
      "margin_balance_amount": 549176982000,
      "short_balance_lots": 220000,
      "foreign_tx_futures_long_oi": 9000,
      "foreign_tx_futures_short_oi": 70000,
      "foreign_tx_futures_net_oi": -61000,
      "twse_listed_maintenance_ratio": 188.9,
      "market_index_close": 23149.46,
      "market_trade_value_amount": 980000000000,
      "bias_rate_ma5_pct": -0.15,
      "bias_rate_ma20_pct": 2.36,
      "bias_rate_ma60_pct": 7.11,
      "bias_rate_ma120_pct": 22.9185,
      "bias_rate_ma240_pct": 43.0484
    },
    {
      "trade_date": "2026-06-12",
      "margin_balance_amount": 557027949000,
      "short_balance_lots": 224946,
      "foreign_tx_futures_long_oi": 9318,
      "foreign_tx_futures_short_oi": 74357,
      "foreign_tx_futures_net_oi": -65039,
      "twse_listed_maintenance_ratio": 193.19,
      "market_index_close": 24169.04,
      "market_trade_value_amount": 1169186958350,
      "bias_rate_ma5_pct": 0.95,
      "bias_rate_ma20_pct": 1.38,
      "bias_rate_ma60_pct": 12.8,
      "bias_rate_ma120_pct": 25.3473,
      "bias_rate_ma240_pct": 45.9886
    }
  ]
}

32C.4 GET /api/daily-market/index-range

取得指定日期區間的大盤層每日時間序列(#1645):/range 已回傳的七個大盤欄位——指數收盤、大盤成交值、五條均線乖離率(MA5/20/60/120/240)——獨立成一支端點,讓只需要這幾欄的呼叫端(cortex-web 首頁 K 線只讀 market_trade_value_amount)不必等 /range 的融資擔保品聚合(上市融資維持率分子)。值與狀態閘門與 /range 走同一組 helper,同一交易日在兩支端點的這七欄逐欄相等。

日期集合:以 market_daily_metrics 有列且該日有 ^TWII K 棒的交易日為準(與 /range 同一 ^TWII 驗證條件)。/range 的日期集合另外聯集 market_chip_metrics,因此 index-range 的日期 ⊆ /range 的日期;差集只會是「籌碼表有列、大盤表無列」的日期,那些日期在 /range 的大盤欄位本來就是 null。七欄全為 null 的列會被剔除。

快取:Redis response cache,key 只綁本端點實際讀取的三張表(market_daily_metrics、^TWII 的 daily_technical_snapshot、^TWII 的 stock_ohlc);晚間籌碼管線(融資券、期貨)寫入不會讓它失效。

Query Parameters:(與 /range 完全相同)

參數 類型 必填 說明
start_date date (YYYY-MM-DD) 否 起始日。省略時 = end_date − default_lookback_days(config/core.yaml 的 daily_market.range_query.default_lookback_days,預設 30)
end_date date (YYYY-MM-DD) 否 結束日。省略時 = 今天
market string 否 市場,預設 taiwan。乖離率只在 taiwan 載入;market_daily_metrics 無 market 欄,非 taiwan 亦回同一份指數/成交值列(與 /range 既有行為一致)

start_date > end_date → 422。

Response 200 欄位:

欄位 類型 nullable 說明
market string no 市場
start_date string no 實際採用的起始日
end_date string no 實際採用的結束日
count integer no daily 筆數
daily array no 每交易日一筆,依日期升冪
daily[].trade_date string no 交易日
daily[].market_index_close number yes 大盤指數(TAIEX/^TWII)收盤;允許 derived_available
daily[].market_trade_value_amount integer yes 大盤成交值(元);僅 available
daily[].bias_rate_ma5_pct number yes 大盤收盤相對 MA5 的乖離率(%)
daily[].bias_rate_ma20_pct number yes 大盤收盤相對 MA20 的乖離率(%)
daily[].bias_rate_ma60_pct number yes 大盤收盤相對 MA60 的乖離率(%)
daily[].bias_rate_ma120_pct number yes 大盤收盤相對 MA120(半年線)的乖離率(%);null 規則同 /range
daily[].bias_rate_ma240_pct number yes 大盤收盤相對 MA240(年線)的乖離率(%);null 規則同 /range

Example: GET /api/daily-market/index-range?start_date=2026-06-11&end_date=2026-06-12

{
  "market": "taiwan",
  "start_date": "2026-06-11",
  "end_date": "2026-06-12",
  "count": 2,
  "daily": [
    {
      "trade_date": "2026-06-11",
      "market_index_close": 23149.46,
      "market_trade_value_amount": 980000000000,
      "bias_rate_ma5_pct": -0.15,
      "bias_rate_ma20_pct": 2.36,
      "bias_rate_ma60_pct": 7.11,
      "bias_rate_ma120_pct": 22.9185,
      "bias_rate_ma240_pct": 43.0484
    },
    {
      "trade_date": "2026-06-12",
      "market_index_close": 24169.04,
      "market_trade_value_amount": 1169186958350,
      "bias_rate_ma5_pct": 0.95,
      "bias_rate_ma20_pct": 1.38,
      "bias_rate_ma60_pct": 12.8,
      "bias_rate_ma120_pct": 25.3473,
      "bias_rate_ma240_pct": 45.9886
    }
  ]
}

32D. 全市場多面向日報分析

GET /api/recap-analysis/latest

讀取最新已預熱的全市場日報。此端點為公開、唯讀、cache-only; request 不會呼叫 LLM,也不會寫入資料庫。

回傳的日報不保證與當下原始資料完全同步。原始資料在日報產生後仍可能 被上游修正(例如隔日補抓的 ETF 持股),此端點不因此拒絕回傳;新舊改由 trade_date(日報對應的交易日)與 generated_at(產生時間)表達, 由呼叫端自行判讀。

Query Parameters:

參數 類型 必填 說明
analysis_type technical \| chip \| financial \| integrated \| active_etf 是 日報面向;active_etf 僅支援 taiwan
market string 否 市場,預設 taiwan

五種面向各自回傳該面向最新一筆 period_type='daily' 的快取列 (依 period_end_date 由新到舊取第一筆)。integrated 讀自己的快取列, 不要求與 technical / chip / financial 同一交易日 —— 各面向的 trade_date 可能不同,以各自回應中的 trade_date 為準。

共同 Response envelope:

{
  "analysis_type": "financial",
  "market": "taiwan",
  "trade_date": "2026-07-28",
  "period_type": "daily",
  "generated_at": "2026-07-28T10:00:00Z",
  "analysis": {
    "trade_date": "2026-07-28",
    "headline": "全市場財務摘要",
    "market_summary": "市場整體說明",
    "growth_summary": "成長分布說明",
    "valuation_summary": "估值分布說明",
    "industry_summary": "產業強弱說明",
    "stock_summary": "代表個股說明",
    "risk_summary": "資料覆蓋與風險限制",
    "risks": []
  }
}

analysis 依 analysis_type 使用不同 typed schema:

類型 專屬摘要欄位
technical breadth_summary, group_summary, stock_summary
chip group_summary, stock_summary, etf_summary
financial growth_summary, valuation_summary, industry_summary, stock_summary
integrated technical_summary, chip_summary, financial_summary, alignment_summary
active_etf report, generation_mode, fallback_reason, facts;完整契約

原有 technical/chip/financial/integrated 四種類型均包含 trade_date、headline、market_summary、 risk_summary、risks、period_type、period_start_date、 period_end_date、period_summary。

錯誤:

  • 404 {"error":{"code":"latest market recap analysis not available","message":"latest market recap analysis not available","details":{}}}:該面向沒有任何 daily 快取列,或該列的 analysis_json 不符 schema、缺少時間戳。 原始資料變動不再構成 404。
  • 422:缺少 analysis_type 或值不在允許集合。

33. 用戶反饋 Feedback

認證: Bearer Token(一般用戶)

POST /api/feedback

提交反饋。

Request Body:

{
  "title": "圖表載入很慢",          // string, 1-200 chars, required
  "description": "首頁技術分析圖表要等 10 秒以上"  // string, min 1 char, required
}

Response 201:

{
  "id": "uuid",
  "title": "圖表載入很慢",
  "status": "pending"
}

Error Codes: 422 validation error, 401 unauthenticated


GET /api/feedback/me

查看自己的反饋歷史(不含已封存)。

Query Parameters:

參數 類型 預設 說明
page int 1 頁碼(≥1)
page_size int 20 每頁筆數(1-100)

Response 200:

{
  "total": 5,
  "page": 1,
  "page_size": 20,
  "items": [
    {
      "id": "uuid",
      "title": "圖表載入很慢",
      "description": "...",
      "status": "pending",
      "created_at": "2026-04-01T10:00:00"
    }
  ]
}

不回傳 admin_note、notion_*、reporter_email、archived_at


34. 管理端反饋 Admin Feedback

認證: Bearer Token + Admin 權限(is_admin = true) 非 Admin 存取任何 endpoint → 403 ADMIN_REQUIRED

GET /api/admin/feedback

列出所有反饋(分頁、可篩選)。

Query Parameters:

參數 類型 預設 說明
page int 1 頁碼
page_size int 20 每頁筆數(1-100)
status string null 篩選 status(pending/acknowledged/resolved/ignored)
include_archived bool false 是否包含已封存反饋

Response 200: 同用戶端格式,但含 member_id、reporter_email、admin_note、notion_page_id、notion_category


GET /api/admin/feedback/{id}

查看單筆完整詳情(含 Notion export state、admin_note、archived_at)。

Response 200: 全欄位 | 404 不存在


PATCH /api/admin/feedback/{id}/status

更新 domain status。

Request Body:

{ "status": "acknowledged" }   // pending | acknowledged | resolved | ignored

Response 200: { "id": "uuid", "status": "acknowledged" } | 404 | 422 invalid status


PATCH /api/admin/feedback/{id}/note

新增/更新管理備註。

Request Body:

{ "admin_note": "已確認:iPhone 15, iOS 18.2, 4G 環境下重現" }

Response 200: { "id": "uuid", "admin_note": "..." } | 404


POST /api/admin/feedback/{id}/export-notion

匯出反饋至 Notion(3-phase race condition protection)。

Request Body:

{ "category": "bug" }    // bug | feature_request | general

Response 200: { "id": "uuid", "notion_page_id": "notion-page-abc" }

Error Codes: - 409 已匯出或匯出進行中 - 422 invalid category - 502 Notion API 失敗(已自動 rollback PENDING 狀態)


POST /api/admin/feedback/{id}/archive

Soft delete(設定 archived_at)。

Response 200: { "id": "uuid", "archived": true } | 404 不存在或已封存


35. 使用者群組 User Groups

使用者自建股票群組,可獨立生成策略規則與 match。所有 endpoint 需要 JWT 認證,僅 group owner 可存取。

POST /api/user-groups

建立新群組。

參數 類型 說明
group_name string 群組名稱(1-100 字元)
description string? 群組描述

Response 201: UserGroupResponse

GET /api/user-groups

列出認證用戶擁有的所有群組。

Response: UserGroupResponse[]

PATCH /api/user-groups/{group_id}

更新群組名稱、描述或啟用狀態。

參數 類型 說明
group_name string? 新名稱
description string? 新描述
is_active bool? 啟用/停用

Response: UserGroupResponse | 403 非 owner | 404 不存在

DELETE /api/user-groups/{group_id}

刪除群組(cascade 刪除成員關係)。

Response 204 | 403 非 owner | 404 不存在


GET /api/user-groups/{group_id}/members

列出群組中的活躍成員。

Response: UserGroupMemberResponse[]

POST /api/user-groups/{group_id}/members

新增成員。若先前已移除,新增一筆新的 membership 紀錄(保留歷史;不更新舊紀錄的 removed_at)。

參數 類型 說明
ticker string 股票代碼

Response 201: UserGroupMemberResponse | 409 已在群組中

DELETE /api/user-groups/{group_id}/members/{ticker}

軟移除成員(設定 removed_at,保留歷史紀錄)。

Response 204 | 404 不在群組中


GET /api/user-groups/{group_id}/rules

查詢群組的策略規則(scope=user_group, visibility=private)。

參數 類型 說明
source_window query 2y(預設)/ all,見 §1 通用說明
include_below_floor query true 回傳所有品質層級(預設 false,僅回傳 qualified 及舊版資料)

Response: UserGroupRuleResponse[]

欄位 類型 說明
rule_id string? 規則 ID
strategy_key string? 策略鍵
domain string? TA / CT / IR / COM
regime_slice string? all / bull / bear / range
metrics_2y object? 2y 窗口 8 項指標(hit_rate, mean_return, expectancy, sample_count, profit_factor, ...);見 §1
metrics_all object? 全期窗口 8 項指標;見 §1

GET /api/user-groups/{group_id}/matches

查詢群組的策略 match(預設 limit 200)。

參數 類型 說明
trade_date query? 日期 YYYY-MM-DD,不指定則回傳最近
source_window query 2y(預設)/ all,見 §1 通用說明
include_below_floor query true 回傳所有品質層級(預設 false,僅回傳 qualified 及舊版資料)

Response: UserGroupMatchResponse[]

欄位 類型 說明
ticker string 股票代碼
trade_date string 交易日
strategy_key string? 策略鍵
rule_id string? 規則 ID
score float? 綜合分數
metrics_2y object? 2y 窗口 8 項指標;見 §1
metrics_all object? 全期窗口 8 項指標;見 §1
rank int? 排名
is_primary bool? 是否主要規則
domain string? TA / CT / IR / COM
regime_slice string? all / bull / bear / range

UserGroupResponse

欄位 類型 說明
group_id string 群組 UUID
group_name string 群組名稱
description string? 描述
is_active bool 是否啟用
member_count int 活躍成員數
created_at string? 建立時間
updated_at string? 更新時間

UserGroupMemberResponse

欄位 類型 說明
ticker string 股票代碼
added_at string? 加入日期
removed_at string? 移除日期(null=活躍)

ETF Holdings

POST /api/etf-holdings/{etf_ticker}/aetf-agent/analyze

管理員產生或重用單一主動式股票 ETF 報告。etf_ticker 必須是 canonical .TW/.TWO ticker;query date 可省略、force 預設 false。analysis.sections 保留操作總結、今日操作、近 5 日、近 20 日、近期績效五個機械化詳情與完整 facts;閱讀正文使用 analysis.report。aetf-v14 報告專用 serializer 的三組資料為目前持股 current_holdings、每日快照增減 daily_changes、逐股 5/20 日合計 stock_summary,保留 holdings_freshness 及各績效期間的 baseline_date/end_date;不接收預寫結論。

送模資料的 scope.covers_entire_fund 描述資產範圍,periods.*.data_status 描述範圍內股數可判定性;各期間有基準日、期末日及官方 interval_count,累計不含基準日當天異動。每日保留無異動日及 all_stocks_unknown/unknown_tickers 例外;逐股完整無異動提供零值,不完整期間的合計保持 null,可確認區間另標。ending_streak 區分方向/正日數、確認無連續的 none/0 與未知 null。估值缺價與公司行動覆蓋未知不補零或 false。每日及合計使用同一批標準化變動,共通語意集中於 semantics;詳細結構見 report_data 契約。

若排除項含未識別或海外股票,正文必須以「就台股部位而言」限制操作結論;只有現金等非股票列被排除時不要求此範圍句,避免把 cash-only 排除誤寫成股票涵蓋不足。

config/core.yaml 的 mas.agents.aetf.llm_enabled 預設 false;只有布林 true 啟用 LLM,缺省或無效型別皆 fail closed。使用者指定 prompt 要求三段、約 250~400 字純正文,不輸出 JSON/引用 ID。啟用時,受支援的期間、股票、數值、方向、績效與必要警語 claim 會綁回 facts;格式或驗證檢查未通過時 fail closed 回模板,null/缺列不解讀為零。驗證器是有界的自然語言防線,不宣稱能證明任意語句的完整語意。關閉時回 HTTP 200 機械模板與 fallback_reason=llm_disabled;啟用後 LLM 故障仍回模板並揭露原有 fallback reason。生成模式納入 cache input hash,因此下一次 POST 不會沿用相反模式的舊報告;GET 仍只回已存 cache,不讀 config 或重生。API request/response 欄位不變,fallback_reason enum 為 additive 擴充。

requested date 界定可使用的官方交易日上限;anchor_date 是該上限內最近的合格官方交易日。只有 requested date 等於台北今天且現在早於共用 DAILY_STOCK_UPDATE_TIME(15:30)時排除今天,15:30 起納入;歷史請求不套今天的時間限制,週末/休市不額外退一個 session。服務另找出此 ETF 最後一個同步成功、非空且 fetched_count 等於實際持久化列數的 snapshot 作為 holdings_as_of。持股 21-session 窗口結束於 holdings_as_of,績效 21-session 窗口獨立結束於 official anchor_date。holdings_freshness.lag_session_count 只數兩日之間的官方 sessions;週末/休市 requested date 不增加 lag,長期 lag 不觸發任意 stale cutoff。中間 session 缺 snapshot 時保留缺口並降級 coverage,不跨日拼接,也不把未知當成零。狀態為 stale 時正文必須揭露持股日與官方 session lag;performance 各期保留獨立 baseline_date、end_date。收盤資料時間已到但指定日缺價時仍為 partial/null,不以舊價格選新 anchor。今天為官方交易日時,同日跨過 15:30 再次 POST 會依新窗口 input hash 重算;週末/休市不因跨過此時間而改變窗口。公開 GET 不因時間經過自動生成。

GET /api/etf-holdings/{etf_ticker}/aetf-agent/latest

公開 cache-only 讀取。可用 date=YYYY-MM-DD 指定 requested report date;省略時取該 ETF 當前 report version 最新一筆。此端點不讀持股 source、不呼叫 LLM。

兩端點成功時皆回 AETFReportResponse。POST 需要 admin auth:缺少必要 Authorization header 回 422,無效/過期 token 回 401,停權或非管理員回 403,token 對應的會員不存在/已刪除回 404,會員或管理員權限 schema 尚未就緒回 503;ETF 存在但不是 enabled 主動式股票 ETF 回 400,ETF 不存在、requested date(含)以前沒有任何有效完整 snapshot,或官方 calendar 在有效 anchor(含)以前本身不足 21 個 session 時回 404,ticker 非 canonical、日期在未來或超出官方日曆範圍回 422。ETF 的 snapshot 筆數不決定官方 session 是否足夠:新上市基金只有 1 個有效 snapshot,但 calendar 可選滿 21 個官方 session 時仍回 200;其餘歷史持股或價格缺漏由各窗口的 partial|unavailable 揭露,不把資料缺口偽裝成找不到資源。

GET 僅驗證 ticker 與 query 的輸入格式後查 cache;找不到當前 report version 的指定日期/最新一筆時回 404。它不重讀基金主檔、官方日曆或 source,因此不會重新判斷 ETF 目前是否 enabled/主動式,也不會把語法有效但未命中 cache 的未來/日曆外日期改判為 POST 的 domain 422。cache payload 或資料庫執行錯誤回 500。完整 schema、窗口完整度、公司行動、逐日估值、raw-close 績效、cache 與相容性規則見 AETF Agent 契約。現有 ETF holdings 與綜合日報契約不變。

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

新增管理員產生入口,無 body;可選 query date=YYYY-MM-DD(預設台北今日)、force=false。回傳 analysis_type=active_etf 的 latest wrapper 與完整綜合日報,包含 ETF 漲跌、最多 5 檔具名增減持、張數/預估金額及併入個股的近期趨勢。LLM 以句子級事實片語安排自然銜接;股票/ETF 名稱與代號、方向、張數、估算金額與雙向操作均由程式片語保護,不能由自由文字改寫。最近五個交易日的原始明細與診斷清單僅保留於 facts。force=false 不呼叫 LLM,只回同一輸入的既有快取或固定模板(fallback_reason=llm_deferred);force=true 立刻送一次 LLM 作為手動補救,不占用每日排程名額;若送出失敗而同一份輸入已有 LLM 報告,回傳該報告不變(200、generated_at 不更新),失敗只記在 active_etf_recap_llm_sends。否則 LLM 無法使用或輸出未通過驗證時仍回 200 固定模板,analysis.generation_mode=template 與 fallback_reason 說明來源。

來源不足回 404;非法/未來日期或缺必要 Authorization header 回 422;無效憑證/權限不足沿用 401/403;資料庫錯誤不偽裝成 fallback。讀取使用公開 GET /api/recap-analysis/latest?analysis_type=active_etf&market=taiwan。

雙向持股增減具名列出雙方張數、預估金額及淨結果,不使用強弱門檻。報告版本或 facts 改變時,下一次重算以固定模板覆寫同日快取;LLM 只在排程時段送出(當晚 20:05 補抓後,失敗時 21:30、23:00 再試;晚到資料改變輸入時隔天 09:30 再送一次),每個資料日最多兩次成功送出。公開 GET /api/recap-analysis/latest 只讀快取,不呼叫 LLM。一般同內容呼叫沿用快取;管理員可用 force=true 手動送 LLM。舊 legacy 快取的讀取投影保留原始 generation_mode 等 metadata,並不表示投影後文字是新版 LLM 生成。

完整 request/response、facts 欄位、計算與快取規則、錯誤 envelope 及前端接法見 主動式 ETF 綜合日報契約。原有持股查詢與 recap/daily GET 行為不變。

台股 ETF 持股查詢、歷史快照、Daily Recap 與個股反查。資料來源涵蓋 13 家投信、約 274 檔 ETF(國泰、元大、富邦、中信、群益、台新/新光、復華、野村、第一金、兆豐、統一、安聯、永豐)。

GET /api/etf-holdings/performance

查詢 ETF 在指定本期區間內的價格績效。此 endpoint 比較 ETF 自身在 stock_ohlc 的收盤價,不比較持股成分。

參數 位置 類型 預設 說明
isActive query bool false true 僅回傳主動式 A 類 ETF;false 回傳所有 enabled ETF
period query string day day 前一交易日收盤至當日/最新可得收盤、week 上週最後交易日收盤至本週至今、month 上月底/本月前最後交易日收盤至本月至今、q 上季末/本季前最後交易日收盤至本季至今、year 去年底/今年前最後交易日收盤至本年至今。其他值回傳 422
date query string 今日 anchor date,格式 YYYY-MM-DD。指定後可查該日期所屬的日/週/月/季/年

期間以台灣本地日期為準。未傳 date 時使用今日;傳入 date 時,day/week/month/q/year 都以該日期作為 anchor。period=day 的 current_close_price 為 anchor date 當日或之前最新一筆有效 stock_ohlc.close,baseline_close_price 為 current_date 前一個有效收盤價,用來呈現單日漲跌。若 anchor date 尚無價格,current_date 會回退到最新可得價格日,並透過 effective_date(top-level)與 per-item current_date 表明實際價格日期,同時 is_fallback=true、requested_date 保留使用者指定值。period=week/month/q/year 的 current_close_price 為 period end 當日或之前最新一筆有效 stock_ohlc.close,baseline_close_price 為 period start 之前最後一筆有效 stock_ohlc.close,因此 period=week 在週二會使用上週最後交易日作 baseline,而不是週一;period=month/q/year 也會分別使用本月/本季/今年開始前最後一個有效收盤價。有效價格會排除 NULL 與 PostgreSQL NUMERIC NaN。若某 ETF 找不到 period start 前 baseline(例如期間內才上市),則改用 period 內最早一筆有效收盤價作 baseline,呈現其上市以來於該期間的漲跌;若該 ETF 在期間內僅有單一有效價格(此時 baseline_date 等於 current_date),baseline/current 相同且報酬為 0;若沒有有效價格,該 ETF 不會出現在 etfs;若全部 ETF 都沒有有效價格,回傳 200、total=0、etfs=[]。

Response 200

{
  "period": "week",
  "is_active_filter": true,
  "window_start": "2026-05-11",
  "window_end": "2026-05-17",
  "price_start_date": "2026-05-08",
  "effective_date": "2026-05-15",
  "requested_date": "2026-05-17",
  "is_fallback": true,
  "total": 2,
  "etfs": [
    {
      "etf_ticker": "00981A.TW",
      "etf_name": "主動式 ETF 名稱",
      "is_active_etf": true,
      "market": "taiwan",
      "baseline_date": "2026-05-08",
      "baseline_close_price": 10.15,
      "current_date": "2026-05-15",
      "current_close_price": 10.72,
      "price_delta": 0.57,
      "price_change_pct": 5.615763546798029
    }
  ]
}

Response 200(無有效價格)

{
  "period": "week",
  "is_active_filter": true,
  "window_start": "2026-05-11",
  "window_end": "2026-05-17",
  "price_start_date": null,
  "effective_date": null,
  "requested_date": "2026-05-17",
  "is_fallback": true,
  "total": 0,
  "etfs": []
}

Status/Error

  • 422:date 不是 YYYY-MM-DD;period 非 day / week / month / q / year;isActive 不是布林值。
  • 沒有 404:無有效價格回 200 空 payload(見上)。
  • 合法日期沒有上限:晚於最新價格日的 date(含 9999-12-31)照上方規則回 200,is_fallback=true。

body 形狀見「通用 Response 格式」:date 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;period / isActive 由 FastAPI 驗證,回 {"detail": [...]}。

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

Response 422(period 非法值,例:period=bogus)

{
  "detail": [
    {
      "type": "enum",
      "loc": ["query", "period"],
      "msg": "Input should be 'day', 'week', 'month', 'q' or 'year'",
      "input": "bogus",
      "ctx": {"expected": "'day', 'week', 'month', 'q' or 'year'"}
    }
  ]
}

Response 422(isActive 非布林值,例:isActive=maybe)

{
  "detail": [
    {
      "type": "bool_parsing",
      "loc": ["query", "isActive"],
      "msg": "Input should be a valid boolean, unable to interpret input",
      "input": "maybe"
    }
  ]
}

Response Model: ETFPerformanceResponse

欄位 類型 說明
period string day / week / month / q / year
is_active_filter bool 是否只回傳主動式 ETF
window_start date 查詢區間起日
window_end date 查詢區間迄日
price_start_date date? response 內 ETF 實際最早 baseline/current 價格日;可能早於 window_start;無資料為 null
effective_date date? response 內 ETF 實際最新價格日(即「資料時間」);無資料為 null。重命名自 price_end_date
requested_date date? 使用者透過 date 參數指定的日期;未指定為 null
is_fallback bool requested_date 有帶且 effective_date != requested_date(含 effective_date 為 null)時為 true。effective_date 在所有 path 下都 <= requested_date,故 != 與 < 等價
total int etfs 筆數
etfs ETFPerformanceItem[] ETF 績效列表,依 price_change_pct 降序、etf_ticker 升序

Response Model: ETFPerformanceItem

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 中文名稱
is_active_etf bool 是否主動式 ETF
market string ETF market
baseline_date date period=day 為 current 前一個有效價格日;period=week/month/q/year 為 period start 前最後一個有效價格日;若無(期間內才上市)則為 period 內最早一筆有效價格日。僅當該 ETF 在期間內只有單一價格點時,baseline_date 才會等於 current_date(報酬為 0)
baseline_close_price float baseline_date 收盤價
current_date date 期間內最新有效價格日
current_close_price float current_date 收盤價
price_delta float current_close_price - baseline_close_price
price_change_pct float price_delta / baseline_close_price * 100;baseline 為 0 時回傳 0

GET /api/etf-holdings/stock-actions/ranking

跨 ETF 彙總持股異動,依個股買入/賣出估算金額排行。金額使用「ETF 持股異動股數 × 該成分股在 source date 的個股收盤價」;不是 ETF 本身成交量或 ETF 收盤價。多檔 ETF 對同一個股同方向異動會合併為同一排名列,並在 actions 保留 ETF-level 明細。

參數 位置 類型 預設 說明
date query string? 最新成功快照日 YYYY-MM-DD;有帶時使用該日或之前最新成功 ETF 持股快照,並以 is_fallback 標示是否回退。完全無 snapshot 時回 200 空 payload,不回 404
isActive query bool false true 僅回傳主動式 ETF (is_active_etf=true) 的持股異動;false 回傳所有 enabled ETF
period query string day day 保留既有「當日快照 vs 前一成功快照」;week / month / q / year 使用與 ETF 績效排行相同的 anchor window,改以 period baseline holdings vs current holdings 計算區間持股異動。非法值回 422
limit query int config etf_holdings.stock_action_ranking.default_limit 每個方向最多回傳幾筆,需介於 1 與 config max_limit

period 沒帶時預設 day。period=week|month|q|year 時,date 為 anchor date;未傳 date 時以最新成功 ETF 持股快照日作 anchor。window_start / window_end 與 GET /api/etf-holdings/performance 相同:週期起日到 anchor。

逐檔基準日解析(per-ETF anchor resolution):baseline/current 日期逐檔各自解析,不使用全域單一日期的精確比對——每檔 ETF 各自的 current snapshot 為該檔在 window 內的最新成功快照,baseline snapshot 優先使用該檔在 window_start 前最新成功快照,若不存在則 fallback 到該檔在 window 內最早的成功快照(僅當該最早快照早於該檔 current 時才視為可比較,否則該檔被排除)。此設計避免任一檔在某個精確錨點日缺漏 sync 就導致整批 ETF 被排除。total_etfs 為可解析出 (baseline, current) 兩者的 ETF 數;previous_date 為這些 ETF 各自 baseline 中的最早值,effective_date 為各自 current 中的最晚值(無任何可解析 ETF 時 effective_date 退回 window 內的全域最新快照日)。個股排行 amount/current_close_price 使用該檔自己 current 日的收盤價,而非全域最新 window 日期的收盤價——兩檔 ETF 的 current 日不同時,排行計算仍各自正確。

以上為 period≠day 的規則。period=day 的基準日語意不同:baseline 必須是 day baseline(納入 ETF 中各自前一筆成功快照日的最晚值)。前一筆快照早於 day baseline 的 ETF 只能算出跨多日的累積變動,會被排除出排行、改列 stale_etfs——詳見 GET /api/etf-holdings/recap/daily 的 period=day 說明。

day 只納入「前一筆成功快照 == day baseline」的 ETF;漏掉 day baseline 的檔案無法計算單日變動,改列 stale_etfs(見下)。首次快照的 ETF 不受此限,維持全部持股列為 added。

added 與 quantity_increased 會進入 buy_rankings;removed 與 quantity_decreased 會進入 sell_rankings。quantity_delta 在買入為正、賣出為負;amount 使用絕對股數計算。若成分不是 asset_type='stock'、缺少可計算股數、或找不到有效個股收盤價,該 action 不會進入排行,並在 summary 的 excluded counts 中揭露。

Response 200

{
  "period": "day",
  "window_start": "2026-04-25",
  "window_end": "2026-04-25",
  "effective_date": "2026-04-25",
  "previous_date": "2026-04-24",
  "requested_date": "2026-04-25",
  "is_fallback": false,
  "is_active_filter": false,
  "limit": 10,
  "summary": {
    "total_etfs": 2,
    "etfs_with_changes": 2,
    "buy_action_count": 3,
    "sell_action_count": 2,
    "buy_ranked_count": 2,
    "sell_ranked_count": 2,
    "excluded_missing_quantity_count": 0,
    "excluded_missing_price_count": 0,
    "excluded_non_stock_count": 0
  },
  "buy_rankings": [
    {
      "rank": 1,
      "side": "buy",
      "raw_code": "2330",
      "constituent_ticker": "2330.TW",
      "constituent_name": "台積電",
      "asset_type": "stock",
      "amount": 135000.0,
      "quantity_delta": 150.0,
      "current_close_price": 900.0,
      "etf_count": 2,
      "actions": [
        {
          "etf_ticker": "00981A.TW",
          "etf_name": "主動統一台股增長",
          "is_active_etf": true,
          "change_type": "added",
          "prev_quantity": null,
          "curr_quantity": 100.0,
          "quantity_delta": 100.0,
          "prev_weight_pct": null,
          "curr_weight_pct": 8.5,
          "weight_delta_pct": null,
          "current_close_price": 900.0,
          "amount": 90000.0
        }
      ]
    }
  ],
  "sell_rankings": []
}

Response 200(無 snapshot)

無任何成功快照(DB 完全無資料 或 period 內無資料)時,回 200 空 payload,不回 404:

{
  "period": "day",
  "window_start": null,
  "window_end": null,
  "effective_date": null,
  "previous_date": null,
  "requested_date": "2099-01-01",
  "is_fallback": true,
  "is_active_filter": false,
  "limit": 10,
  "summary": {
    "total_etfs": 0,
    "etfs_with_changes": 0,
    "buy_action_count": 0,
    "sell_action_count": 0,
    "buy_ranked_count": 0,
    "sell_ranked_count": 0,
    "excluded_missing_quantity_count": 0,
    "excluded_missing_price_count": 0,
    "excluded_non_stock_count": 0
  },
  "buy_rankings": [],
  "sell_rankings": []
}

Status/Error

  • 422:date 不是 YYYY-MM-DD;period 非 day / week / month / q / year;limit 不是整數、小於 1 或大於 config max_limit(預設 200);isActive 不是布林值。
  • 沒有 404:無任何成功快照回 200 空 payload(見上)。

body 形狀見「通用 Response 格式」:date 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;period / limit / isActive 由 FastAPI 驗證,回 {"detail": [...]}。

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

Response 422(period 非法值,例:period=bogus)

{
  "detail": [
    {
      "type": "enum",
      "loc": ["query", "period"],
      "msg": "Input should be 'day', 'week', 'month', 'q' or 'year'",
      "input": "bogus",
      "ctx": {"expected": "'day', 'week', 'month', 'q' or 'year'"}
    }
  ]
}

Response 422(limit 越界,例:limit=201)

{
  "detail": [
    {
      "type": "less_than_equal",
      "loc": ["query", "limit"],
      "msg": "Input should be less than or equal to 200",
      "input": "201",
      "ctx": {"le": 200}
    }
  ]
}

Response Model: ETFStockActionRankingResponse

欄位 類型 說明
period string day / week / month / q / year,未傳 query 時為 day
window_start date? period 查詢區間起日;day 時等於 effective_date;無 snapshot 時 null
window_end date? period 查詢區間迄日;day 時等於 effective_date;無 snapshot 時 null
effective_date date? 實際使用的 ETF 持股快照日(即「資料時間」)。逐檔 current 中的最晚值(無可解析 ETF 時退回 window 內全域最新快照日);無 snapshot 時 null。重命名自 date
previous_date date? ETF holdings diff 使用的 baseline 快照日。day 為逐檔前一成功快照中的最晚值(day baseline;baseline 更早的 ETF 不列入排行,改列 stale_etfs);period≠day 為逐檔 baseline 中的最早值;無可解析 ETF 時 null
stale_etfs StaleETF[] 無法計算單日變動而被排除出排行的 ETF(period≠day 恆為 [])
requested_date date? 使用者指定的日期;未指定為 null
is_fallback bool requested_date 有帶且 effective_date != requested_date(含 effective_date 為 null)時為 true
is_active_filter bool 是否套用主動式 ETF filter
limit int 每個方向實際套用的筆數上限
summary ETFStockActionRankingSummary ETF/action/ranking/exclusion 計數;無 snapshot 時全部 0
buy_rankings ETFStockActionRankingItem[] 個股買入估算金額排行,依 amount 降序;無 snapshot 時空陣列
sell_rankings ETFStockActionRankingItem[] 個股賣出估算金額排行,依 amount 降序;無 snapshot 時空陣列

Response Model: ETFStockActionRankingSummary

欄位 類型 說明
total_etfs int 目標日有成功快照的 ETF 數。period=day 時包含被列入 stale_etfs、未實際參與比較的 ETF(即 total_etfs ≥ 參與 diff 的 ETF 數 + stale_etfs 長度);period≠day 為逐檔可解析出 baseline/current 兩者的 ETF 數
etfs_with_changes int 有持股異動的 ETF 數
buy_action_count int 買入候選 action 數,含非個股或缺資料排除者;不含不可解析為台股價格 ticker 的海外股票
sell_action_count int 賣出候選 action 數,含非個股或缺資料排除者;不含不可解析為台股價格 ticker 的海外股票
buy_ranked_count int limit 前的買入個股排行列數
sell_ranked_count int limit 前的賣出個股排行列數
excluded_missing_quantity_count int 因缺少可計算股數而排除的 action 數
excluded_missing_price_count int 可定價台股候選因缺少有效個股收盤價而排除的 action 數
excluded_non_stock_count int 因非個股資產而排除的 action 數

Response Model: ETFStockActionRankingItem

欄位 類型 說明
rank int 1-based 排名
side string buy / sell
raw_code string 成分股 raw code
constituent_ticker string? 正規化個股 ticker
constituent_name string? 股票主檔名稱;對不到主檔時為投信回報的原始名稱
asset_type string 排行列預期為 stock
amount float 同一個股同方向 action 的加總估算金額
quantity_delta float 同一個股同方向 action 的加總異動股數;買入為正、賣出為負
current_close_price float? 用於金額計算的個股收盤價
etf_count int 貢獻此個股排行列的 ETF 數
actions ETFStockActionDetail[] ETF-level 買賣明細

Response Model: ETFStockActionDetail

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 名稱
is_active_etf bool 該 ETF 是否主動式
change_type string added / removed / quantity_increased / quantity_decreased
prev_quantity float? 前次持股股數
curr_quantity float? 本次持股股數
quantity_delta float 本 action 異動股數;買入為正、賣出為負
prev_weight_pct float? 前次持股權重
curr_weight_pct float? 本次持股權重
weight_delta_pct float? curr_weight_pct - prev_weight_pct,無法計算時為 null
current_close_price float 用於金額計算的個股收盤價
amount float abs(quantity_delta) * current_close_price

GET /api/etf-holdings/stock-actions/{raw_code}

以個股為主體,回傳該個股在區間內買方與賣方所有 ETF 的操作明細,每檔 ETF 另附區間內的逐日操作。供「點選個股 → 展開對應 ETF 買賣明細」使用。

與 /stock-actions/ranking 的差異:

  • 不排名、不截斷。ranking 每個方向最多回 limit(上限 200)筆,實測 period=week 的 buy_ranked_count=246、sell_ranked_count=281,代表榜單確實會被截斷;本 endpoint 已鎖定單一個股,不受該限制,「另一側沒有資料」在此可安全解讀為「該側無操作」。
  • 附逐日。ranking 的 prev_*/curr_* 是 window 兩端的兩點比較;本 endpoint 的 daily_actions 把該區間拆成相鄰快照之間的移動。
  • 窗語意完全相同,共用同一個 window/fallback 解析器,故兩者可由同一個期間控制驅動。
參數 位置 類型 預設 說明
raw_code path string — 成分股 raw code,取自 /stock-actions/ranking 回應的 raw_code(已正規化為台股 4 碼,如 3037)。長度上限 60,對應 etf_holdings.raw_constituent_code 欄寬
date query string? 最新成功快照日 同 /stock-actions/ranking
isActive query bool false 同 /stock-actions/ranking
period query string day day / week / month / q / year,語意與 window 同 /stock-actions/ranking

raw_code 的正規化:daily_actions 比對 holdings 時套用與持股 loader 同一份正規化規則(RAW_CODE_NORMALIZATION_CASE_SQL)——Bloomberg 2330 TT(7 碼)與台股 ISIN TW0002330008(12 碼)都會收斂為 2330。請一律傳排行回應給的 raw_code,不要傳 provider 原始碼。

逐日的認定:一筆 daily_actions 代表該 ETF 對該個股的持有在兩個相鄰成功快照之間真的動了——持有與否改變(added / removed),或股數改變(quantity_increased / quantity_decreased)。只有 weight_pct 變動而股數不變者不列入:那是同一檔 ETF 內其他成分股變動造成的權重漂移,不是對本個股的買賣。持有與否以 holdings 列是否存在判定,不以 quantity 是否為 null 判定(只報權重的 provider 會有一列但 quantity 為 null)。

與 /changes/recent 的門檻差異(刻意為之,2026-08-08 定案):本 endpoint 採「股數只要不同就是變動」;/{etf_ticker}/changes/recent 走 detect_holdings_changes,有 QUANTITY_CHANGE_THRESHOLD_RATIO(0.1%)門檻,quantity 缺值時另以 WEIGHT_CHANGE_THRESHOLD_PCT(0.05pp)判定。

因此 0.1% 以下的微調在本 endpoint 會列出、在 changes/recent 不會。實例:00927.TW 持有 2330 於 2026-07-20 → 07-21 由 3,005,000 股增至 3,008,000 股(+0.0998%),本 endpoint 列為一筆 quantity_increased,changes/recent 的 action 日期序列則自 07-20 直接跳至 07-22。

保留全量而不套門檻,是為了維持「daily_actions 是區間彙總的完整分解」這項性質——逐日 quantity_delta 加總恆等於該檔的區間 quantity_delta。若套上門檻,被濾掉的微調仍計入彙總,明細就會加不回總數。呼叫端若只想呈現「有意義的調倉」,請自行以 abs(quantity_delta) / prev_quantity 過濾。

逐日與彙總的對帳關係:同一檔 ETF 的 daily_actions[].quantity_delta 加總,等於該檔的區間 quantity_delta。added 計為 +curr_quantity、removed 計為 -prev_quantity(與排行彙總共用同一份實作),故區間內進出場也對得起來。

窗內首筆的前一個快照落在 window_start 之前(即區間 baseline),與彙總的逐檔基準日解析一致;若該檔在 window 前無任何成功快照,其窗內最早一筆沒有可比較的前一筆,不產生對應的 daily_actions。

Response 200

{
  "raw_code": "3037",
  "constituent_ticker": "3037.TW",
  "constituent_name": "欣興",
  "asset_type": "stock",
  "period": "week",
  "window_start": "2026-08-03",
  "window_end": "2026-08-07",
  "effective_date": "2026-08-07",
  "previous_date": "2026-07-31",
  "requested_date": null,
  "is_fallback": false,
  "is_active_filter": false,
  "buy_amount": 4910988000.0,
  "sell_amount": 143825865.0,
  "buy_etf_count": 14,
  "sell_etf_count": 2,
  "excluded_missing_quantity_count": 0,
  "excluded_missing_price_count": 0,
  "stale_etfs": [],
  "buy_actions": [
    {
      "etf_ticker": "00981A.TW",
      "etf_name": "主動統一台股增長",
      "is_active_etf": true,
      "change_type": "quantity_increased",
      "prev_quantity": 17833000.0,
      "curr_quantity": 20210000.0,
      "quantity_delta": 2377000.0,
      "prev_weight_pct": 4.87,
      "curr_weight_pct": 6.46,
      "weight_delta_pct": 1.59,
      "current_close_price": 955.0,
      "amount": 2270035000.0,
      "daily_actions": [
        {
          "as_of_date": "2026-08-03",
          "previous_date": "2026-07-31",
          "change_type": "quantity_increased",
          "prev_quantity": 17833000.0,
          "curr_quantity": 17992000.0,
          "quantity_delta": 159000.0,
          "prev_weight_pct": 4.87,
          "curr_weight_pct": 5.16,
          "weight_delta_pct": 0.29
        },
        {
          "as_of_date": "2026-08-04",
          "previous_date": "2026-08-03",
          "change_type": "quantity_increased",
          "prev_quantity": 17992000.0,
          "curr_quantity": 20210000.0,
          "quantity_delta": 2218000.0,
          "prev_weight_pct": 5.16,
          "curr_weight_pct": 6.26,
          "weight_delta_pct": 1.1
        }
      ]
    }
  ],
  "sell_actions": []
}
欄位 類型 說明
raw_code string 查詢的成分股 raw code,原樣回傳
constituent_ticker / constituent_name / asset_type string? 該個股識別資訊;區間內無任何操作時為 null。constituent_name 為股票主檔名稱;對不到主檔時為投信回報的原始名稱
period / window_start / window_end / effective_date / previous_date / requested_date / is_fallback / is_active_filter — 語意同 /stock-actions/ranking
buy_amount / sell_amount float 該方向所有 ETF 的估算金額合計
buy_etf_count / sell_etf_count int 該方向的 ETF 檔數
excluded_missing_quantity_count int 本個股因缺股數而未納入的 action 數
excluded_missing_price_count int 本個股因缺收盤價而未納入的 action 數
stale_etfs array 同 /recap/daily;period≠day 恆為 []
buy_actions / sell_actions array 欄位同 ranking 的 actions[],另加 daily_actions
buy_actions[].daily_actions array 該 ETF 在區間內的逐日操作,依 as_of_date 遞增;欄位同 /{etf_ticker}/changes/recent 的 StockDailyAction

個股在區間內完全沒有 ETF 操作時回 200 空 payload(buy_actions / sell_actions 皆為 [],識別欄位為 null),不回 404。

Status/Error

  • 422:date 不是 YYYY-MM-DD;period 非 day / week / month / q / year;isActive 不是布林值;raw_code 超過 60 字元。
  • 沒有 404:個股在區間內無 ETF 操作,或指定 date 以前無任何成功快照,皆回 200 空 payload(見上)。

body 形狀見「通用 Response 格式」:date 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;period / isActive / raw_code 長度由 FastAPI 驗證,回 {"detail": [...]}。

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

Response 422(period 非法值,例:period=bogus)

{
  "detail": [
    {
      "type": "enum",
      "loc": ["query", "period"],
      "msg": "Input should be 'day', 'week', 'month', 'q' or 'year'",
      "input": "bogus",
      "ctx": {"expected": "'day', 'week', 'month', 'q' or 'year'"}
    }
  ]
}

Response 422(raw_code 超過 60 字元,例:61 個 A)

{
  "detail": [
    {
      "type": "string_too_long",
      "loc": ["path", "raw_code"],
      "msg": "String should have at most 60 characters",
      "input": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
      "ctx": {"max_length": 60}
    }
  ]
}

GET /api/etf-holdings/{etf_ticker}

查詢單一 ETF 最新成功的持股快照。

參數 位置 類型 說明
etf_ticker path string ETF 代號(如 0050.TW、00400A.TW)
date query string? YYYY-MM-DD,省略為最新成功快照日。指定日期無快照時回傳 404

回傳 ETFHoldingsResponse。404 表示 ETF 不存在、尚無成功快照,或指定日期無快照。

holdings[] 會對 asset_type='stock' 的成分股補上價格摘要:

  • current_close_price:as_of_date 當日或之前最近一筆有效 stock_ohlc.close
  • baseline_close_price:current_close_price 前一筆有效 stock_ohlc.close
  • price_delta:current_close_price - baseline_close_price
  • price_change_pct:price_delta / baseline_close_price * 100

若標準化 ticker 查不到價格,會在台股上市/上櫃尾碼間 fallback(例如 5347.TW 可 fallback 查 5347.TWO)。非股票或無有效 OHLC 時價格欄位為 null。

部分投信來源若提供 Bloomberg-style 台股代號(例如 2368 TT),API 會正規化為 raw_code=2368,並回填 2368.TW 或 2368.TWO。

Status/Error

  • 404:etf_ticker 格式合法但不存在於 etf_funds(ETF '{etf_ticker}' not found;.TW / .TWO 尾碼與 ETF 實際上市類型不符,例如 0050.TWO,也回此訊息);ETF 存在但尚無任何成功快照(No successful holdings snapshot for '{etf_ticker}');有帶 date 但該日無快照(No holdings snapshot for '{etf_ticker}' on {date},不會回退到更早的快照)。
  • 422:date 不是 YYYY-MM-DD;etf_ticker 不是合法的台股 ticker。

body 形狀見「通用 Response 格式」:以上皆由端點自行拒絕(HTTPException),回 {"error": {...}} envelope。etf_ticker 格式錯誤的 code 有三種:Taiwan equity stock_code must be a bare Taiwan code(例:XXX.TW、ABCD.TW)、Taiwan equity ticker requires a .TW or .TWO suffix(例:2330)、ETF holdings support Taiwan only(例:AAPL、7203.T)。此檢查先於 404:格式不合法的 ticker 不會走到「不存在」。

Response 404(ETF 不存在,例:99999.TW)

{
  "error": {
    "code": "ETF '99999.TW' not found",
    "message": "ETF '99999.TW' not found",
    "details": {}
  }
}

Response 404(指定日期無快照,例:00891.TW?date=2020-01-01)

{
  "error": {
    "code": "No holdings snapshot for '00891.TW' on 2020-01-01",
    "message": "No holdings snapshot for '00891.TW' on 2020-01-01",
    "details": {}
  }
}

Response 422(etf_ticker 格式不合法,例:XXX.TW)

{
  "error": {
    "code": "Taiwan equity stock_code must be a bare Taiwan code",
    "message": "Taiwan equity stock_code must be a bare Taiwan code",
    "details": {}
  }
}

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

GET /api/etf-holdings/{etf_ticker}/history

查詢歷史持股快照。

參數 位置 類型 說明
etf_ticker path string ETF 代號
start_date query string? YYYY-MM-DD,預設 end_date 前 30 天
end_date query string? YYYY-MM-DD,省略為該 ETF 最近成功快照日。無快照時回傳 404

回傳 ETFHistoryResponse。

Status/Error

  • 400:start_date 晚於 end_date(start_date must be on or before end_date;省略 end_date 時以該 ETF 最近成功快照日比較,例如 start_date=2030-01-01 同樣回此訊息);或區間超過上限,即 end_date - start_date 大於 etf_holdings.max_history_range_days(預設 365)天(Date range exceeds 365 days)。
  • 404:etf_ticker 格式合法但不存在於 etf_funds(ETF '{etf_ticker}' not found;.TW / .TWO 尾碼與 ETF 實際上市類型不符,例如 0050.TWO,也回此訊息);或省略 end_date 且該 ETF 無任何成功快照(No successful holdings snapshot for '{etf_ticker}')。明確帶 end_date 但該日以前沒有快照不是 404,回 200 且 snapshots=[]。
  • 422:start_date / end_date 不是 YYYY-MM-DD;etf_ticker 不是合法的台股 ticker。

body 形狀見「通用 Response 格式」:以上皆由端點自行拒絕(HTTPException),回 {"error": {...}} envelope。etf_ticker 格式錯誤的 code 有三種:Taiwan equity stock_code must be a bare Taiwan code(例:XXX.TW、ABCD.TW)、Taiwan equity ticker requires a .TW or .TWO suffix(例:2330)、ETF holdings support Taiwan only(例:AAPL、7203.T)。此檢查先於 404:格式不合法的 ticker 不會走到「不存在」。

Response 400(start_date 晚於 end_date,例:start_date=2026-10-19&end_date=2026-10-13)

{
  "error": {
    "code": "start_date must be on or before end_date",
    "message": "start_date must be on or before end_date",
    "details": {}
  }
}

Response 400(區間超過 365 天,例:start_date=2024-01-01&end_date=2026-10-19)

{
  "error": {
    "code": "Date range exceeds 365 days",
    "message": "Date range exceeds 365 days",
    "details": {}
  }
}

Response 404(ETF 不存在,例:99999.TW)

{
  "error": {
    "code": "ETF '99999.TW' not found",
    "message": "ETF '99999.TW' not found",
    "details": {}
  }
}

Response 422(etf_ticker 格式不合法,例:XXX.TW)

{
  "error": {
    "code": "Taiwan equity stock_code must be a bare Taiwan code",
    "message": "Taiwan equity stock_code must be a bare Taiwan code",
    "details": {}
  }
}

Response 422(start_date / end_date 格式錯誤,例:start_date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

GET /api/etf-holdings/{etf_ticker}/changes

查詢單一 ETF 在某日或指定 period 相對 baseline 快照的變動明細。

參數 位置 類型 說明
etf_ticker path string ETF 代號
date query string? YYYY-MM-DD,省略為該 ETF 最近成功快照日。day period 若該日無 snapshot,會 fallback 到 ≤ requested_date 之最新 snapshot 並以 is_fallback=true 標示;該 ETF 完全無 snapshot 時回 200 空 payload,不回 404。404 僅在 etf_ticker 不存在於 etf_funds 時回傳
period query string 預設 day。day 保留既有「指定/最新快照 vs 前一成功快照」;week / month / q / year 使用與 ETF 績效排行相同的 anchor window,改以 period baseline holdings vs current holdings 計算區間持股異動。非法值回 422

period 沒帶時預設 day。period=week|month|q|year 時,date 為 anchor date;未傳 date 時以該 ETF 最新成功快照日作 anchor。window_start / window_end 與 GET /api/etf-holdings/performance 相同。current snapshot 為該 ETF 在 window_end 當日或之前、且落在 window 內的最新成功快照;baseline snapshot 優先使用 window_start 前最新成功快照,若不存在則使用 window 內最早且可比較的成功快照。沒有可比較 baseline 時不會把第一筆持股全部算成買入,而是以既有 200 response shape 回傳空異動。

回傳 ETFChangesResponse。response top-level 會包含 period、window_start、window_end、effective_date、requested_date、is_fallback,以及 ETF 本身在 effective_date 的價格 summary:current_close_price、baseline_close_price、price_delta、price_change_pct。current_close_price 為 effective_date 當日或之前最近一筆可用 OHLC 收盤價,baseline_close_price 為該 current 價格日期之前最近一筆可用 OHLC 收盤價。可用代表 close 不是 NULL / NaN。

Note:ETFChangesResponse 不再 inherit ETFRecapItem。ETFRecapItem.as_of_date 仍由 GET /api/etf-holdings/recap/daily 的 etfs[] 使用,不受本 endpoint 改名影響。

added、removed、quantity_changes 的每一筆 HoldingChange 也會包含該成分股在 effective_date 的同格式價格 summary,並使用同一套可用 OHLC 規則。成分股價格查詢會先使用 constituent_ticker exact match;若該筆是台股股票且 exact ticker 查不到,會用同一個 raw_code 嘗試 .TW / .TWO exchange suffix fallback。若 ETF 或成分股無法對應到 stock_ohlc、缺少 current 可用收盤價、缺少基準可用收盤價,或基準收盤價為 0,無法取得或無法計算的欄位會回傳 null。

added、removed、quantity_changes 各自回傳穩定排序。排序先依 change type 分組;同一 group 內,有可用代表持股權重的項目會排在沒有權重的項目前面,再依代表持股權重降序:added 使用 curr_weight_pct,removed 使用 prev_weight_pct,quantity_changes 使用 max(curr_weight_pct, prev_weight_pct)。接著,有可計算 weight_delta 的項目會排在沒有 delta 的項目前面,再依 |weight_delta|、|quantity_delta| 降序,最後依 raw_code 升序。

Response 200(正常)

{
  "etf_ticker": "0050.TW",
  "etf_name": "元大台灣50",
  "is_active_etf": false,
  "period": "day",
  "window_start": "2026-04-25",
  "window_end": "2026-04-25",
  "effective_date": "2026-04-25",
  "previous_date": "2026-04-24",
  "requested_date": "2026-04-25",
  "is_fallback": false,
  "current_close_price": 128.0,
  "baseline_close_price": 120.0,
  "price_delta": 8.0,
  "price_change_pct": 6.666666666666667,
  "added": [
    {
      "raw_code": "2317",
      "constituent_ticker": "2317.TW",
      "constituent_name": "鴻海",
      "asset_type": "stock",
      "change_type": "added",
      "prev_quantity": null,
      "curr_quantity": 200.0,
      "prev_weight_pct": null,
      "curr_weight_pct": 5.0,
      "current_close_price": 168.5,
      "baseline_close_price": 166.0,
      "price_delta": 2.5,
      "price_change_pct": 1.5060240963855422
    }
  ],
  "removed": [],
  "quantity_changes": [
    {
      "raw_code": "2330",
      "constituent_ticker": "2330.TW",
      "constituent_name": "台積電",
      "asset_type": "stock",
      "change_type": "quantity_increased",
      "prev_quantity": 900.0,
      "curr_quantity": 1000.0,
      "prev_weight_pct": 46.0,
      "curr_weight_pct": 46.5,
      "current_close_price": 872.0,
      "baseline_close_price": 864.0,
      "price_delta": 8.0,
      "price_change_pct": 0.9259259259259258
    }
  ]
}

Response 200(無 snapshot)

ETF 存在但完全無成功 snapshot(例如剛新增的 ETF 還沒抓資料),或 explicit date 之前完全無 snapshot 時,回 200 空 payload:

{
  "etf_ticker": "0050.TW",
  "etf_name": "元大台灣50",
  "is_active_etf": false,
  "period": "day",
  "window_start": null,
  "window_end": null,
  "effective_date": null,
  "previous_date": null,
  "requested_date": "2099-01-01",
  "is_fallback": true,
  "current_close_price": null,
  "baseline_close_price": null,
  "price_delta": null,
  "price_change_pct": null,
  "added": [],
  "removed": [],
  "quantity_changes": []
}

Status/Error

  • 404:僅 etf_ticker 格式合法但不存在於 etf_funds(ETF '{etf_ticker}' not found;.TW / .TWO 尾碼與 ETF 實際上市類型不符,例如 0050.TWO,也回此訊息)。ETF 存在但無快照、或 date 之前無快照,不是錯誤,回 200 空 payload(見上)。
  • 422:period 非 day / week / month / q / year;date 不是 YYYY-MM-DD;etf_ticker 不是合法的台股 ticker。

body 形狀見「通用 Response 格式」:404、date 與 etf_ticker 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;period 由 FastAPI 驗證,回 {"detail": [...]}。etf_ticker 格式錯誤的 code 有三種:Taiwan equity stock_code must be a bare Taiwan code(例:XXX.TW、ABCD.TW)、Taiwan equity ticker requires a .TW or .TWO suffix(例:2330)、ETF holdings support Taiwan only(例:AAPL、7203.T)。此檢查先於 404:格式不合法的 ticker 不會走到「不存在」。

Response 404(ETF 不存在,例:99999.TW)

{
  "error": {
    "code": "ETF '99999.TW' not found",
    "message": "ETF '99999.TW' not found",
    "details": {}
  }
}

Response 422(etf_ticker 格式不合法,例:XXX.TW)

{
  "error": {
    "code": "Taiwan equity stock_code must be a bare Taiwan code",
    "message": "Taiwan equity stock_code must be a bare Taiwan code",
    "details": {}
  }
}

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

Response 422(period 非法值,例:period=bogus)

{
  "detail": [
    {
      "type": "enum",
      "loc": ["query", "period"],
      "msg": "Input should be 'day', 'week', 'month', 'q' or 'year'",
      "input": "bogus",
      "ctx": {"expected": "'day', 'week', 'month', 'q' or 'year'"}
    }
  ]
}

Response Model: ETFChangesResponse

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 中文名稱
is_active_etf bool 是否主動式 ETF
period string day / week / month / q / year
window_start date? period 查詢區間起日;無 snapshot 時 null
window_end date? period 查詢區間迄日;無 snapshot 時 null
effective_date date? 實際使用的持股 snapshot 日(即「資料時間」)。重命名自 as_of_date;無 snapshot 時 null
previous_date date? baseline snapshot 日
requested_date date? 使用者指定的日期;未指定為 null
is_fallback bool requested_date 有帶且 effective_date != requested_date(含 effective_date 為 null)時為 true
current_close_price / baseline_close_price / price_delta / price_change_pct float? ETF 自身 OHLC 價格 summary;無 snapshot 時 null
added / removed / quantity_changes HoldingChange[] 持股異動明細;無 snapshot 時空陣列

價格欄位

欄位 類型 說明
current_close_price float? top-level 為 etf_ticker 在 response as_of_date 當日或之前最近一筆可用 stock_ohlc.close;HoldingChange 內優先為 constituent_ticker 的同規則價格,台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO fallback
baseline_close_price float? top-level 為 etf_ticker 在 current 價格日期之前最近一筆可用 stock_ohlc.close;HoldingChange 內優先為 constituent_ticker 的同規則價格,台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO fallback
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100

GET /api/etf-holdings/{etf_ticker}/changes/recent

查詢單一 ETF 最近 N 個成功快照交易日內,每一檔有變動個股的逐日交易動作時間軸(stock-major)。

參數 位置 類型 說明
etf_ticker path string ETF 代號(不存在於 etf_funds → 404)
days query int 欲回溯的交易日數量,範圍 1 ≤ days ≤ etf_holdings.max_recent_changes_days(預設 fallback 30)。超界 → 422

語義說明:

  • 取該 ETF etf_holdings_sync 中 status='success' 且 fetched_count > 0 的最新 N 個 as_of_date。每個入選日 D_i 與其「前一個成功快照日 D_prev」(可能在視窗外更早)比對。
  • days_returned 反映實際可比 diff 筆數(== 視窗內能配到 previous_date 的快照日數量),非入選快照數。例如:ETF 僅有 1 個成功快照 → days_returned=0、stocks=[];首日無 prev 自動跳過。
  • 整個 N 天視窗內無 trading action(既未被增刪、quantity / weight 也未跨 threshold)的個股不列入 stocks;如需完整持股清單請使用 GET /{etf_ticker}/history。
  • stocks 排序:依累計 |weight_delta_pct| 降序、次依 len(actions) 降序、末依 raw_code 升序保證穩定。
  • start_date / end_date 對應 daily_diffs 首末日,無 diff 時為 null。
  • response top-level 會包含 ETF 本身與 /changes 相同格式的價格 summary:current_close_price 對應 response start_date 到 end_date 之間最新可取得的 OHLC 收盤價,baseline_close_price 對應同一區間內最早可取得的 OHLC 收盤價。成分股價格查詢與 /changes 一樣,優先使用 constituent_ticker exact match;台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO fallback。可取得代表 close 不是 NULL / NaN。price_delta = current_close_price - baseline_close_price,price_change_pct = price_delta / baseline_close_price * 100。
  • 每檔 stocks[] 也會包含同格式的成分股價格 summary,並使用同一個 response start_date 到 end_date 價格區間與同一套 NULL / NaN 過濾規則。這是整個 N 日變化的價格 summary,不是逐個 actions[] 的單日變化。

Response 200(正常)

{
  "etf_ticker": "0050.TW",
  "etf_name": "元大台灣50",
  "is_active_etf": false,
  "days_requested": 5,
  "days_returned": 5,
  "start_date": "2026-05-07",
  "end_date": "2026-05-13",
  "current_close_price": 128.0,
  "baseline_close_price": 120.0,
  "price_delta": 8.0,
  "price_change_pct": 6.666666666666667,
  "stocks": [
    {
      "raw_code": "2330",
      "constituent_ticker": "2330.TW",
      "constituent_name": "台積電",
      "asset_type": "stock",
      "current_close_price": 100.0,
      "baseline_close_price": 80.0,
      "price_delta": 20.0,
      "price_change_pct": 25.0,
      "actions": [
        {
          "as_of_date": "2026-05-09",
          "previous_date": "2026-05-08",
          "change_type": "quantity_increased",
          "prev_quantity": 12000000.0,
          "curr_quantity": 12500000.0,
          "quantity_delta": 500000.0,
          "prev_weight_pct": 48.10,
          "curr_weight_pct": 48.55,
          "weight_delta_pct": 0.45
        }
      ]
    }
  ]
}

Response 200(ETF 存在但無快照)

{
  "etf_ticker": "00999.TW",
  "etf_name": "某新上市 ETF",
  "is_active_etf": false,
  "days_requested": 5,
  "days_returned": 0,
  "start_date": null,
  "end_date": null,
  "current_close_price": null,
  "baseline_close_price": null,
  "price_delta": null,
  "price_change_pct": null,
  "stocks": []
}

Status/Error

  • 404:etf_ticker 格式合法,但不存在於 etf_funds(ETF '{etf_ticker}' not found)。ETF 存在但無快照不是錯誤,回 200(見上)。
  • 422:days 缺少、不是整數、小於 1 或大於 etf_holdings.max_recent_changes_days(type 分別為 missing / int_parsing / greater_than_equal / less_than_equal);etf_ticker 不是合法的台股 ticker。

body 形狀見「通用 Response 格式」:404 與 etf_ticker 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;days 由 FastAPI 驗證,回 {"detail": [...]}。etf_ticker 格式錯誤的 code 有三種:Taiwan equity stock_code must be a bare Taiwan code(例:XXX.TW、ABCD.TW)、Taiwan equity ticker requires a .TW or .TWO suffix(例:2330)、ETF holdings support Taiwan only(例:AAPL、7203.T)。此檢查先於 404:格式不合法的 ticker 不會走到「不存在」。

Response 404(ETF 不存在,例:99999.TW)

{
  "error": {
    "code": "ETF '99999.TW' not found",
    "message": "ETF '99999.TW' not found",
    "details": {}
  }
}

Response 422(etf_ticker 格式不合法,例:XXX.TW)

{
  "error": {
    "code": "Taiwan equity stock_code must be a bare Taiwan code",
    "message": "Taiwan equity stock_code must be a bare Taiwan code",
    "details": {}
  }
}

Response 422(days 越界)

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": ["query", "days"],
      "msg": "Input should be greater than or equal to 1",
      "input": "0",
      "ctx": {"ge": 1}
    }
  ]
}

回傳 ETFRecentChangesResponse。

Response Model: ETFRecentChangesResponse
欄位 類型 說明
etf_ticker string 請求路徑帶入的 ETF 代號
etf_name string? 來自 etf_funds.etf_name
is_active_etf bool 是否為主動式 ETF
days_requested int client 傳入的 days
days_returned int 實際可比 diff 筆數(0 ≤ days_returned ≤ days_requested)
start_date date? 最舊有 diff 的 as_of_date;無 diff → null
end_date date? 最新有 diff 的 as_of_date;無 diff → null
current_close_price float? ETF 在 response start_date 到 end_date 之間最新可取得的 OHLC 收盤價
baseline_close_price float? ETF 在 response start_date 到 end_date 之間最早可取得的 OHLC 收盤價
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100
stocks StockActionTimeline[] 每檔有動作個股的 timeline
Response Model: StockActionTimeline
欄位 類型 說明
raw_code string 投信原始代碼
constituent_ticker string? 標準化 ticker
constituent_name string? 股票主檔名稱;對不到主檔時為投信回報的原始名稱
asset_type string stock / bond / future / ...
current_close_price float? 成分股在 response start_date 到 end_date 之間最新可取得的 OHLC 收盤價;台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO fallback
baseline_close_price float? 成分股在 response start_date 到 end_date 之間最早可取得的 OHLC 收盤價;台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO fallback
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100
actions StockDailyAction[] 按 as_of_date 升序
Response Model: StockDailyAction
欄位 類型 說明
as_of_date date 該動作發生日
previous_date date? 對比基準日(可能在視窗外更早)
change_type string added / removed / quantity_increased / quantity_decreased
prev_quantity float? added 時為 null
curr_quantity float? removed 時為 null
quantity_delta float? curr - prev;任一 null → null
prev_weight_pct float? added 時為 null
curr_weight_pct float? removed 時為 null
weight_delta_pct float? curr - prev;任一 null → null

GET /api/etf-holdings/{etf_ticker}/estimated-costs

查詢單一 enabled 主動式 ETF 目前持有個股的推估成本,以及各股目前成本週期內的推估買賣成本紀錄。後端會讀取全部可用歷史以找出最近一次清倉並重建目前週期,但不回傳已結束的舊週期。這是公開、唯讀 endpoint。

估算限制:本 endpoint 只有每日持股快照,沒有 ETF 實際成交價或日內交易明細。所有金額均以「日終持股數量差 × 該快照日收盤價」估算,不代表基金真實成交成本。

Request

名稱 位置 類型 Required Nullable 預設 說明
etf_ticker path string yes no N/A 完整 ETF public ticker,例如 00993A.TW;只接受 enabled 主動式 ETF
  • 不提供 days query,也沒有 60 天上限。
  • 固定讀取該 ETF 全部 status='success' AND fetched_count > 0,且 fetched_count 等於該 ETF/date 實際持久化總筆數的完整快照;筆數不一致的重抓殘留快照會被排除,排除數量由 excluded_snapshot_count 揭露。
  • 僅納入 asset_type='stock'、quantity_unit='shares' 且有 .TW / .TWO canonical ticker 的成分。
  • 持股讀取沿用既有 ETF read-time canonicalization 與 dedupe,避免 ISIN、2330 TT、plain code 等歷史格式變化把同一股票拆成不同成本週期。

成本規則

  • 第一個完整成功快照已經持有的股票,以該日 exact same-day close 建立 opening_estimate,basis_status=estimated_from_first_observation。這是可觀察歷史起點的估算,不是已驗證實際買入。
  • 股票在第一個完整快照後才首次出現,且先前從未清倉時,以首次買進日 close 建立成本,basis_status=estimated_from_first_buy。
  • 持股增加:新總成本 = 舊總成本 + 增加股數 × 當日 close,平均成本採移動加權平均。
  • 部分賣出:平均成本不變,總成本依剩餘股數等比例下降。
  • 持股在下一個完整成功快照缺席或明確歸零:立即視為清倉,成本、週期起日與該週期 actions 全部丟棄;若最新狀態仍為清倉,該股票不列入 stocks。
  • 清倉後再買:只從再買日建立新成本週期,basis_status=estimated_from_reentry,不承接或回傳清倉前成本與 actions。
  • 買入日缺 close:該週期的總成本與平均成本保持 null,直到清倉後才可由新週期恢復。
  • 賣出日缺 close:該筆賣出金額為 null;若先前成本已知,剩餘總成本與平均成本仍可更新。
  • 同日 close 優先使用 constituent_ticker;exact ticker 缺價時,可使用同 raw_code 的 .TW / .TWO suffix fallback。不得以前一交易日 close 補價。
  • 所有計算處理每一筆非零股數差,不套用既有持股異動畫面的 0.1% 顯示 threshold。
  • quantity 從已知變為 null 或從 null 恢復時,會寫入 direction=unknown 的資料品質 action,且 basis 維持 null,不將未知股數誤報為買賣。
  • 每筆 delta 是兩個納入成本重播的完整快照間的淨變化,金額歸於後一快照日 close;若中間有未發布或被完整性檢查排除的快照,不代表所有交易實際發生於該日。previous_date 指前一個納入的快照日,consumer 應搭配 excluded_snapshot_count 判斷歷史是否存在排除缺口。
  • 股票分割、股票股利等公司行動也可能造成 quantity 變化;目前沒有可靠事件因子可逐筆拆分,因此 action 與成本仍屬推估,不應視為基金揭露的真實成交成本。

Response 200

Top-level ETFEstimatedCostsResponse:

欄位 類型 Nullable 說明
etf_ticker string no ETF public ticker
etf_name string yes ETF 名稱
is_active_etf boolean no 成功時固定為 true
first_snapshot_date date no 全歷史第一個通過完整性檢查的成功非空快照日
last_snapshot_date date no 全歷史最新通過完整性檢查的成功非空快照日
snapshot_count integer no 實際使用的完整快照數,可大於 60
excluded_snapshot_count integer no status='success'、fetched_count > 0,但因持久化筆數不一致而排除的快照數
currency string no 固定 TWD
amount_basis string no 固定 holdings_quantity_delta_x_same_day_close
cost_method string no 固定 moving_weighted_average_same_day_close
stocks ETFEstimatedCostStock[] no 最新完整快照仍持有的股票;已清倉且尚未再買者不回傳
total integer no stocks 數量

ETFEstimatedCostStock:

欄位 類型 Nullable 說明
raw_code string no provider 原始成分代碼
constituent_ticker string no canonical 台股 ticker
constituent_name string yes 股票主檔名稱;對不到主檔時為投信回報的原始名稱
asset_type string no 固定 stock
quantity_unit string no 固定 shares
is_current_holding boolean no 回傳項目固定為 true
current_quantity number yes 最新快照股數;缺量時 null
current_weight_pct number yes 最新快照權重
current_close_price number yes 最新快照日 exact close
estimated_cost_basis number yes 目前部位推估總成本;basis 不完整時 null
estimated_average_cost number yes 目前部位推估每股平均成本
basis_status string no estimated_from_first_observation、estimated_from_first_buy、estimated_from_reentry、missing_buy_close 或 missing_quantity
cost_basis_start_date date yes 目前成本週期起日
actions ETFEstimatedCostAction[] no 依 as_of_date 升序的目前成本週期紀錄;不含最近一次清倉以前的 actions

ETFEstimatedCostAction:

欄位 類型 Nullable 說明
as_of_date date no action 所屬快照日
previous_date date yes 前一成功快照日;opening estimate 為 null
record_type string no opening_estimate、observed_delta 或 unknown_observation
direction string no buy、sell 或股數資料不足時的 unknown
change_type string no initial_observation、added、increased、decreased、quantity_unavailable 或 quantity_restored
previous_quantity number yes 前一快照股數
current_quantity number yes 目前快照股數
quantity_delta number yes current - previous;缺股數時 null
close_price number yes action 日 exact same-day close
estimated_trade_amount number yes abs(quantity_delta) × close_price
amount_status string no available、missing_close 或 missing_quantity
estimated_position_cost_after number yes action 後的推估總成本
estimated_average_cost_after number yes action 後的推估平均成本

範例:

GET /api/etf-holdings/00993A.TW/estimated-costs
{
  "etf_ticker": "00993A.TW",
  "etf_name": "主動安聯台灣",
  "is_active_etf": true,
  "first_snapshot_date": "2026-05-19",
  "last_snapshot_date": "2026-07-23",
  "snapshot_count": 39,
  "excluded_snapshot_count": 0,
  "currency": "TWD",
  "amount_basis": "holdings_quantity_delta_x_same_day_close",
  "cost_method": "moving_weighted_average_same_day_close",
  "stocks": [
    {
      "raw_code": "3443",
      "constituent_ticker": "3443.TW",
      "constituent_name": "創意",
      "asset_type": "stock",
      "quantity_unit": "shares",
      "is_current_holding": true,
      "current_quantity": 33000,
      "current_weight_pct": 1.44,
      "current_close_price": 4380,
      "estimated_cost_basis": 144230000,
      "estimated_average_cost": 4370.606060606061,
      "basis_status": "estimated_from_reentry",
      "cost_basis_start_date": "2026-07-22",
      "actions": [
        {
          "as_of_date": "2026-07-22",
          "previous_date": "2026-07-21",
          "record_type": "observed_delta",
          "direction": "buy",
          "change_type": "added",
          "previous_quantity": 0,
          "current_quantity": 31000,
          "quantity_delta": 31000,
          "close_price": 4370,
          "estimated_trade_amount": 135470000,
          "amount_status": "available",
          "estimated_position_cost_after": 135470000,
          "estimated_average_cost_after": 4370
        }
      ]
    }
  ],
  "total": 1
}

Status/Error

  • 200:成功;active ETF 只有債券/非支援資產時 stocks=[]。
  • 400:ETF 存在,但不是 enabled 主動式 ETF(ETF '{etf_ticker}' must be an enabled active ETF)。
  • 404:ETF 不存在(ETF '{etf_ticker}' not found;.TW / .TWO 尾碼與 ETF 實際上市類型不符,例如 00991A.TWO,也回此訊息),或沒有任何通過筆數完整性檢查的成功非空持股快照(No successful holdings snapshot for '{etf_ticker}')。
  • 422:etf_ticker path 格式不合法,有兩種 body:不符 ^[0-9A-Z]{4,8}\.(TW|TWO)$(例:XXX.TW、bad、AAPL)由 FastAPI 驗證,回 {"detail": [...]};符合該 pattern 但不是合法台股代號(例:ABCD.TW)由端點自行拒絕,回 {"error": {...}} envelope,code 為 Taiwan equity stock_code must be a bare Taiwan code。
  • 500:未預期錯誤,由全域 exception handler 處理,固定回 {"error": {"code": "INTERNAL_ERROR", "message": "Internal server error", "details": {}}},不回傳 SQL 或內部 exception。

400 / 404 由端點自行拒絕(HTTPException),body 為 {"error": {...}} envelope,見「通用 Response 格式」;500 由全域 exception handler 產生,body 固定如上。

Response 400(ETF 存在但不是 enabled 主動式 ETF,例:0050.TW)

{
  "error": {
    "code": "ETF '0050.TW' must be an enabled active ETF",
    "message": "ETF '0050.TW' must be an enabled active ETF",
    "details": {}
  }
}

Response 404(ETF 不存在,例:99999.TW)

{
  "error": {
    "code": "ETF '99999.TW' not found",
    "message": "ETF '99999.TW' not found",
    "details": {}
  }
}

Response 422(etf_ticker 不符 path pattern,例:XXX.TW)

{
  "detail": [
    {
      "type": "string_pattern_mismatch",
      "loc": ["path", "etf_ticker"],
      "msg": "String should match pattern '^[0-9A-Z]{4,8}\\.(TW|TWO)$'",
      "input": "XXX.TW",
      "ctx": {"pattern": "^[0-9A-Z]{4,8}\\.(TW|TWO)$"}
    }
  ]
}

Response 422(符合 path pattern 但不是合法台股代號,例:ABCD.TW)

{
  "error": {
    "code": "Taiwan equity stock_code must be a bare Taiwan code",
    "message": "Taiwan equity stock_code must be a bare Taiwan code",
    "details": {}
  }
}

GET /api/etf-holdings/recap/daily

所有 ETF 持股變動彙總(預設為最近成功快照日)。

參數 位置 類型 說明
date query string? YYYY-MM-DD,省略為最近成功快照日(非當日)。提供日期時回傳該日(含)以前最近成功快照;若該日前無快照則回傳 404(僅 period=day;period≠day 回 200 空 payload)
active_only query bool True 僅列主動式 ETF(代號 A 結尾)
period query enum day(預設)/ week / month / q / year。與 /stock-actions/ranking 的 period 同語意、同 window,單一期間控制可同時驅動兩者

回傳 DailyRecapResponse。

period=day(單日視圖):previous_date 為 day baseline —— 納入 ETF 中各自前一筆成功快照日的最晚值,亦即多數 ETF 實際比對的那一天。只有 baseline 等於 day baseline 的 ETF 會被 diff。某檔 ETF 的前一筆成功快照早於 day baseline 時(其 provider 未在前一交易日發布快照,例如 allianz 的 NavDate 停滯數日),它只能產生跨多日的累積變動,混入單日視圖會灌水,因此不列入 etfs,改由 stale_etfs 回報(見下方 StaleETF)。該檔完全沒有更早成功快照時(即該檔在 date 是首次快照),不視為 stale,全部持股視為 added(維持既有行為)。沒有任何 ETF 有 baseline 時 previous_date 為 null。

period≠day(區間視圖):走與 /stock-actions/ranking 相同的 window 解析與逐檔基準日解析——每檔 ETF 的 current 為該檔在 window 內的最新成功快照,baseline 為該檔在 window_start 前的最新成功快照。此路徑必定能為每檔解析出 baseline,故不會有 stale ETF,stale_etfs 恆為 []。previous_date 為納入 ETF 中各自 baseline 的最早值(與 day path 相反:區間比的是「window 起點之前」的存量,逐檔 baseline 不需一致)。每筆 ETFRecapItem 的 as_of_date / previous_date 為該檔自己的 current / baseline 日期。持股是存量非流量,window 中間缺任何一天的快照都不影響區間淨變動。

每一筆 ETFRecapItem 的 top-level 會包含該 ETF 自身的價格 summary(current_close_price、baseline_close_price、price_delta、price_change_pct),其 added、removed、quantity_changes 內每一筆 HoldingChange 也會包含該成分股的同格式價格 summary。價格 target 為 response date(recap 目標快照日):current_close_price 為該日當日或之前最近一筆可用 stock_ohlc.close,baseline_close_price 為該 current 價格日期之前最近一筆可用收盤價,可用代表 close 不是 NULL / NaN。成分股查詢先用 constituent_ticker exact match;台股股票 exact miss 時可用同 raw_code 的 .TW / .TWO exchange suffix fallback。無法對應 stock_ohlc、缺 current、缺 baseline、或 baseline 為 0 時,無法取得或計算的欄位回傳 null。此價格規則與 GET /{etf_ticker}/changes 完全一致。

排序差異(注意):與 GET /{etf_ticker}/changes 不同,recap/daily 的 added、removed、quantity_changes 不套用 impact 排序,維持 build_daily_recap 原始輸出順序。此為刻意保留既有行為,前端如需排序請自行於 client 端處理。

Status/Error

  • 404:僅 period=day。指定 date 當日(含)以前無成功快照(No successful ETF holdings snapshot found on or before {date}),或未指定 date 且完全無成功快照(No successful ETF holdings snapshot found)。period≠day 在同樣情況回 200 空 payload(etfs=[]、previous_date、window_start、window_end 為 null),不回 404。
  • 422:date 不是 YYYY-MM-DD;period 非 day / week / month / q / year;active_only 不是布林值。

body 形狀見「通用 Response 格式」:404 與 date 格式錯誤由端點自行拒絕(HTTPException),回 {"error": {...}} envelope;period / active_only 由 FastAPI 驗證,回 {"detail": [...]}。

Response 404(period=day 且指定日期以前無成功快照,例:date=2000-01-01)

{
  "error": {
    "code": "No successful ETF holdings snapshot found on or before 2000-01-01",
    "message": "No successful ETF holdings snapshot found on or before 2000-01-01",
    "details": {}
  }
}

Response 422(date 格式錯誤,例:date=notadate)

{
  "error": {
    "code": "Invalid date format. Expected YYYY-MM-DD.",
    "message": "Invalid date format. Expected YYYY-MM-DD.",
    "details": {}
  }
}

Response 422(period 非法值,例:period=bogus)

{
  "detail": [
    {
      "type": "enum",
      "loc": ["query", "period"],
      "msg": "Input should be 'day', 'week', 'month', 'q' or 'year'",
      "input": "bogus",
      "ctx": {"expected": "'day', 'week', 'month', 'q' or 'year'"}
    }
  ]
}

Response Models

ETFHoldingItem

欄位 類型 說明
raw_code string 投信原始代碼(保證金 / 現金統一為 CASH:CCY / MARGIN:CCY)
constituent_ticker string? 標準化 ticker,僅股票型標的填入
constituent_name string? 股票主檔名稱;對不到主檔時為投信回報的原始名稱
asset_type string stock / bond / future / cash / etf / reit / other
weight_pct float? 權重 %
quantity float? 股數 / 口數 / 面額
quantity_unit string? shares / contracts / amount / units
current_close_price float? 成分股在快照日或之前最新有效 stock_ohlc.close
baseline_close_price float? 成分股在 current_close_price 前一筆有效 stock_ohlc.close
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100

ETFHoldingsResponse

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 中文名稱
is_active_etf bool 是否主動式 ETF
as_of_date date 快照日期
holdings ETFHoldingItem[] 持股明細,依權重降序
total int 持股檔數

HoldingChange

欄位 類型 說明
raw_code string 投信原始代碼
constituent_ticker string? 標準化 ticker
constituent_name string? 股票主檔名稱;對不到主檔時為投信回報的原始名稱
asset_type string 資產類型
change_type string added / removed / quantity_increased / quantity_decreased
prev_quantity float? 前一日股數
curr_quantity float? 當日股數
prev_weight_pct float? 前一日權重
curr_weight_pct float? 當日權重
current_close_price float? 成分股在 response as_of_date 當日或之前最近一筆有效 stock_ohlc.close
baseline_close_price float? 成分股在前一個 OHLC 交易日的 stock_ohlc.close
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100

ETFRecapItem

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 中文名稱
is_active_etf bool 是否主動式 ETF
as_of_date date? 快照日期(實際資料日,不一定是當日)
previous_date date? 該檔自己的比對基準日。period=day 時等於 response 的 day baseline;period≠day 時為該檔在 window_start 前的最新成功快照日
current_close_price float? ETF 在 response as_of_date 當日或之前最近一筆有效 stock_ohlc.close
baseline_close_price float? ETF 在前一個 OHLC 交易日的 stock_ohlc.close
price_delta float? current_close_price - baseline_close_price
price_change_pct float? price_delta / baseline_close_price * 100
added HoldingChange[] 新增的持股
removed HoldingChange[] 移除的持股
quantity_changes HoldingChange[] 股數變動超過 0.1% 的持股

DailyRecapResponse

欄位 類型 說明
date date 目標日期
previous_date date? 比對基準日。period=day 為納入 ETF 中各自前一筆成功快照日的最晚值(day baseline,多數 ETF 實際比對的那一天;baseline 更早的 ETF 改列 stale_etfs);period≠day 為納入 ETF 中各自 baseline 的最早值(逐檔基準日解析,見上方 recap/daily 說明)
active_only bool 是否僅列主動式 ETF
period enum 本次 recap 的期間(day / week / month / q / year)
window_start date? 期間 window 起日(day 時等於 date)
window_end date? 期間 window 迄日(day 時等於 date)
etfs ETFRecapItem[] 有變動的 ETF
stale_etfs StaleETF[] 資料未更新、無法計算單日變動而被排除的 ETF(period≠day 恆為 [])
summary DailyRecapSummary 統計摘要

StaleETF

僅出現在 period=day。該 ETF 在 missing_date(day baseline)沒有成功快照——其 provider 未在該日發布持股 (例如 allianz 的 NavDate 停滯),且該 provider 只提供「當前」快照、無歷史查詢,故此缺口無法回補。 它最新的成功快照停在 previous_date,只能算出跨多日的累積變動,因此不列入 etfs 與排行。

欄位 類型 說明
etf_ticker string ETF 代號
etf_name string? ETF 中文名稱
previous_date date 該檔最新一筆成功快照日(早於 day baseline)
missing_date date 缺少快照的那一天,即 day baseline

DailyRecapSummary

欄位 類型 說明
total_etfs int 當日有成功快照的 ETF 總數。含 stale_etfs 內未參與比較的 ETF——「有變動」與「資料未更新」之外的差額才是「無變動」
etfs_with_changes int 其中有變動的 ETF 數

StockInfo 新增欄位

GET /api/stocks/{ticker} 回傳的 StockInfo 新增 etfs 陣列:個股被哪些 ETF 持有(取每檔 ETF 最新成功快照),元素為 StockETFInfo { etf_ticker, etf_name?, weight_pct? }。空陣列代表該股票不在任何 ETF 持股中。


26. Observability — Access Log

每一個進入 cortex-api 的 HTTP / SSE 請求都會被 AccessLogMiddleware 攔截,產生一筆完整紀錄寫入 PostgreSQL api_access_log 表。由 scheduler 每日 03:15 (Asia/Taipei) 清理;retention / cron 設定都在 config/core.yaml 的 logging.access_log 區段。

保留天數依部署環境而異,由 scheduler 容器的 DEPLOY_ENV 環境變數對應 logging.access_log.retention_days_by_env:

DEPLOY_ENV 保留天數
dev 7
sit 14
uat 14
prod 180

DEPLOY_ENV 未設定、無法辨識或設定值有誤時,退回 logging.access_log.retention_days(180,即 prod 值)並記一筆 WARNING——清理是不可逆的 DELETE,因此失敗方向一律取「保留較多」。此變數與 auth 硬化用的 ENVIRONMENT 是兩個獨立變數,不可混用。

X-Request-ID Header

  • 每一個 response 都會帶 X-Request-ID(16 字元 hex string)。
  • Client 可在 request 端自行提供 X-Request-ID,server 會沿用同一個值;缺則自動產生。
  • 前端在錯誤回報時建議把這個 ID 一併帶上 → 後端 grep / DB 查詢都能對齊單筆紀錄。

敏感資料遮蔽

  • request body / response body / request headers 中,凡是 key 名稱含 password, token, authorization, access_token, refresh_token, client_secret, api_key, jwt, secret, session_id, cookie(不分大小寫、忽略 _ 與 -)一律 mask 為 ***。
  • 非 JSON 文字 body 中若含 Bearer xxx,會被替換為 Bearer ***。
  • mask 清單由 config/core.yaml 的 logging.access_log.mask_keys 控制。

GET /api/admin/access-logs

管理員專用查詢端點。需要管理員權限(members.is_admin = true),非管理員回 403。

Query parameters

參數 類型 說明
from_ts datetime 起始時間(含),ISO 8601
to_ts datetime 結束時間(不含)
status int 精確比對 HTTP status code
status_gte int HTTP status code 下界(含),範圍 100..599
status_lte int HTTP status code 上界(含),範圍 100..599
min_duration_ms float 最短耗時(含),單位毫秒且必須是有限值、>= 0;duration_ms IS NULL 不符合此條件
order_by enum timestamp_desc(預設)或 duration_desc
method string GET / POST / ...(大寫)
path string 子字串比對 request path(LIKE %<path>%)
member_id UUID 過濾特定會員
request_id string 精確查 X-Request-ID
limit int 預設 50,上限 200
offset int 分頁 offset,預設 0

所有參數都可組合,條件間採 AND。status 仍是精確比對;若同時帶級距參數,也必須同時符合。status_gte > status_lte 或任一參數超出規則時回 422。

排序規則:

  • timestamp_desc:timestamp DESC, id DESC,維持既有行為。
  • duration_desc:duration_ms DESC NULLS LAST, timestamp DESC, id DESC。相同耗時以較新的紀錄優先,確保分頁穩定。

常用 drill-down:

GET /api/admin/access-logs?from_ts=2026-07-01T00:00:00%2B08:00&to_ts=2026-08-01T00:00:00%2B08:00&status_gte=500&status_lte=599&order_by=timestamp_desc&limit=50&offset=0
GET /api/admin/access-logs?from_ts=2026-07-01T00:00:00%2B08:00&to_ts=2026-08-01T00:00:00%2B08:00&min_duration_ms=842.7&order_by=duration_desc&limit=50&offset=0

Response

{
  "total": 12345,
  "items": [
    {
      "id": 9001,
      "request_id": "a3f2c8d1b4e5f6a7",
      "timestamp": "2026-05-12T03:00:00.020000+00:00",
      "request_started_at": "2026-05-12T02:59:59.990000+00:00",
      "response_completed_at": "2026-05-12T03:00:00.010000+00:00",
      "method": "GET",
      "path": "/api/stocks/2330.TW",
      "query_string": null,
      "status_code": 200,
      "duration_ms": 12.4,
      "db_duration_ms": 4.21,
      "db_query_count": 3,
      "timing_breakdown": {
        "schema_version": 1,
        "phases_ms": {
          "theme_recap.cache_key": 3.42,
          "theme_recap.cache_lookup": 0.31
        },
        "cache_outcomes": {
          "theme_recap.latest": "hit"
        },
        "db_pool": {
          "acquire_count": 1,
          "acquire_ms": 1.204,
          "queue_wait_ms": 0.012,
          "acquire_outcomes": {"ok": 1},
          "pool_checked_out_at_acquire_max": 2,
          "hold_ms": 7.913,
          "held_at_response_complete": 0
        },
        "sync_cpu": {
          "response_cache.model_validate_json": {"count": 1, "wall_ms": 0.081, "cpu_ms": 0.079}
        },
        "post_response": {
          "access_log.build_and_mask": {"wall_ms": 0.412, "cpu_ms": 0.398}
        }
      },
      "client_ip": "127.0.0.1",
      "transport": "http",
      "member_id": "uuid-of-member-or-null",
      "member_email": "caller@example.com-or-null",
      "member_display_name": "Caller Name-or-null",
      "request_body": "",
      "response_body": "...full masked body...",
      "response_bytes": 1234,
      "request_headers": {"Authorization": "***", "Accept": "application/json"},
      "error_class": null,
      "error_message": null
    }
  ]
}

timestamp 是 access-log 背景 writer 建立/持久化 row 的時間,發生在 response 完成之後,且可能受 queue 延遲影響;它不是請求開始或回應完成時間。請使用 request_started_at 與 response_completed_at 顯示真實的「請求時間/回應時間」,不可由 timestamp ± duration_ms 當成真實值。這兩欄自 migration 117 起才收集,因此歷史 row 為 null;未正常送出 final response body 的例外紀錄,其 response_completed_at 也可能為 null。

db_duration_ms 是同一 request 在 API SQLAlchemy engine 上的 cursor execution 累計毫秒,db_query_count 是相同範圍內的 query 次數;不含 connection-pool 等待、Python mapping/serialization 與背景 access-log INSERT。DB 耗時是資源累計,可能與 timing_breakdown.phases_ms 的執行階段重疊,並行 query 時也可能大於 wall-clock duration,因此不可直接相加;phase 本身也可能巢狀(例如 single_flight_wait 包含 polling 的 cache_lookup),同樣不可相加當作總耗時。timing_breakdown.schema_version 目前為 1;theme latest 可記錄 theme_recap.cache_key、cache_lookup、single_flight_wait、compute、cache_store,cache outcome 為 hit、miss 或 bypass。Migration 147 前的歷史 row 三欄均為 null;新 request 即使沒有 DB query,也會保存 0.0、0 與 schema-v1 空物件。

db_pool、db_pool_spawned_tasks、sync_cpu、post_response 是 #2056 新增的附加 key。schema_version 維持 1,舊讀者可以忽略不認識的 key;#2056 以前寫入的 row 沒有這些 key。db_pool 分開記錄請求本身取得連線的總耗時(acquire_ms)與純 pool queue 等待(queue_wait_ms),以及連線持有時間(hold_ms)。持有時間只計到回應完成為止;回應完成時仍未歸還的連線數記在 held_at_response_complete。db_pool_spawned_tasks 是同一請求衍生的其他 task(fan-out、背景工作、串流 body)在回應完成前的連線活動,不計入 db_pool。sync_cpu 是回應送出前的同步片段,post_response 是回應送出後的 access-log 收尾(串接 body、建立 row 與遮罩);兩者都有 wall 與 thread CPU 毫秒。遮罩與建立 row 不計入 duration_ms;串接 body 發生在 duration_ms 取值之前,因此同時算在兩邊。db_pool 一律存在;db_pool_spawned_tasks、sync_cpu 只在有內容時寫入;post_response 由 access log 收尾時寫入,計時失敗時不寫。這些欄位與 db_duration_ms 一樣不可直接相加。完整定義與判讀方法見 docs/runbooks/api-runtime-metrics.md。

Response wrapper 維持 { "total", "items" };total 是套用全部過濾條件後的筆數,即使 offset 已超出最後一頁仍會回正確 total。

transport 欄位值:http(一般 HTTP 與 SSE/CSV streaming — middleware 自動 stream-tap 抓取完整 body)。(歷史上曾有 ws 值供 WebSocket session 使用;/ws/* 端點已於 #630 移除。)

member_email / member_display_name 由 member_id 左連接 members 取得,供 API monitor 顯示可讀使用者。下列情況會是 null:未帶 Bearer token、token 無效或過期、CORS preflight OPTIONS、公開 endpoint、或歷史 log 指到已刪除/不存在的 member。

退化情境

  • 若 DB 寫入失敗或佇列已滿(預設 10000),row 會被寫入 logs/access_log_fallback.jsonl(jsonline 格式),事後可手動 ingest 回 DB。Request 不會因為 access log 失敗而 block。
  • 非 JSON 的 binary chunk 若無法 utf-8 decode 會被寫成 <binary, N bytes> 占位字串。

26B. Server Metrics

GET /api/admin/server-metrics

管理員專用的 server 管理摘要。需要有效的管理員 Bearer access token;非管理員回 403。

Query parameters

參數 類型 預設 說明
from_date YYYY-MM-DD 今日 Asia/Taipei 起始日(含)
to_date YYYY-MM-DD 今日 Asia/Taipei 結束日(含)
endpoint_limit int 10 熱門 endpoint 筆數,範圍 1..20
  • 未提供日期時查詢台北今日。
  • 只提供其中一個日期時,該日期同時作為起訖日。
  • 日期範圍最多 31 個日曆日;起日晚於訖日或超過上限時回 422。
  • 查詢以 [from_date 00:00, to_date + 1 day 00:00) 計算,因此跨月與月底不需特殊處理。

目前上線定義

users.online_now 完全以 token session 判定,不使用「最近五分鐘有請求」等活動推估。符合下列條件的 distinct member 才列入:

  • refresh_tokens.revoked_at IS NULL
  • access_token_expires_at > generated_at
  • refresh token 的 expires_at > generated_at
  • token session 已建立,且會員狀態為 active

valid_access_token_sessions 是符合相同條件的 session 數,因此同一會員多裝置登入時可能大於 online_now。後端只保存 refresh token hash 與 access token 到期時間,不保存 access bearer token 明文。

目前 token policy 為:

  • access token:60 分鐘
  • refresh token:30 天

logged_in_today、logged_in_this_week、logged_in_this_month 與 logged_in_in_range 是期間內曾簽發 token 的 distinct member 數;為保留登入歷史,已輪替或撤銷的 session 仍會納入期間統計。

連線人數定義

上面所有欄位都源自 refresh_tokens,因此只看得到登入者——未登入的呼叫端根本沒有 token 紀錄。connected_*_in_range 改以 api_access_log 為來源,涵蓋兩者,並以查詢區間(同 query_range,上界會截到 generated_at)為範圍,不做「現在是否在線」判定:

欄位 定義
connected_members_in_range 區間內 member_id 非 NULL 的 distinct member 數
connected_anonymous_ips_in_range 區間內 member_id IS NULL 的 distinct client_ip 數
connected_total_in_range 上述兩者相加

這三個數字是人數的上界,不是精確人數。 兩半可能重疊:public 端點(test_endpoint_auth_gating.py::PUBLIC_ROUTE_PATHS)無 token 即可呼叫,已登入會員的呼叫端若在這些請求上省略 Authorization header,同一人會同時出現在兩半。

早期版本曾用 anti-join 扣除「同區間內也出現在已登入請求上的 IP」試圖去重,該做法已於 #1242 移除:anti-join 的作用域是查詢區間本身,導致聚合非單調——某 IP 若在區間頭幾天匿名、後幾天登入,放大區間反而會讓總數下降,逐日圖表與週月彙總互相矛盾。且該去重只在同一 IP 成立,同一人換網路仍算兩個。以一個壞掉的聚合換取部分去重不划算,故改為保留重疊並如實記載。

現行保證:放大查詢區間,三個數字都不會下降(test_widening_the_range_never_lowers_a_count 釘住)。但仍不可加總——COUNT(DISTINCT) 本質不可加,逐日數字相加不低於該期間的實際區間查詢結果(各日呼叫端完全不重疊時才相等)。

已知限制:

  • 匿名端以 IP 為單位,不是人:同一 NAT/公司網路後方的多人會被算成 1;同一人切換 4G/Wi-Fi 會被算成 2。系統沒有匿名 session id,IP 是請求唯一攜帶的穩定識別。
  • connected_total_in_range 混合了兩種單位:distinct member(人)加 distinct IP(位址)。一位會員用三台裝置貢獻 1,一位匿名者換三個網路貢獻 3,因此登入比例改變時,總數的移動方向取決於呼叫端組成:同一人原本用 N 個位址、登入後收斂成 1,總數下降;但 NAT 後方多人共用一個位址時,其中一人登入會把原本合併的 1 拆成「1 位會員 + 該位址其餘的人」而使總數上升。不可當趨勢指標。當標題數字使用前請理解此性質。
  • 不檢查會員目前狀態,但也不是不變量:online_now 會 JOIN members 並要求 status = 'active',connected_members_in_range 則否——帳號被停權仍計入。但刪除會員會改變歷史數字:api_access_log.member_id 是 ON DELETE SET NULL(migration 051),該員過去的請求會脫離 member 半邊:client_ip 非 NULL 的列改由匿名半邊計(若該位址尚未被計過),client_ip IS NULL 的列則兩半皆不計,總數可能淨減。同一組查詢參數在刪除前後會得到不同結果。
  • logging.access_log.exclude_paths(預設 /health、/、/docs、/openapi.json、/redoc)不寫入 access log,只造訪這些路徑的呼叫端不會被計入。
  • 保留期限依環境而異:api_access_log 由 access_log_cleanup 排程清理,天數取自 logging.access_log.retention_days_by_env,以 DEPLOY_ENV 選定——dev 7 天、SIT/UAT 14 天、prod 180 天。超出保留期的區間查不到連線紀錄;在 SIT 上這代表只能回溯兩週。
  • client_ip 的真實性取決於部署:SIT 由 FORWARDED_ALLOW_IPS 讓 uvicorn 解析 Cloudflare Tunnel 的 X-Forwarded-For,記到的是真實客戶端 IP;本機 dev compose 未設該變數,所有請求會塌成同一個 docker gateway 位址,匿名計數在 dev 無參考價值。

Response 範例

{
  "generated_at": "2026-07-30T08:30:00Z",
  "timezone": "Asia/Taipei",
  "windows": {
    "day_start": "2026-07-30T00:00:00+08:00",
    "week_start": "2026-07-27T00:00:00+08:00",
    "month_start": "2026-07-01T00:00:00+08:00"
  },
  "query_range": {
    "from_date": "2026-07-29",
    "to_date": "2026-07-30",
    "from_ts": "2026-07-29T00:00:00+08:00",
    "to_ts_exclusive": "2026-07-31T00:00:00+08:00",
    "day_count": 2
  },
  "users": {
    "online_now": 42,
    "valid_access_token_sessions": 51,
    "logged_in_today": 80,
    "logged_in_this_week": 240,
    "logged_in_this_month": 620,
    "logged_in_in_range": 135,
    "connected_members_in_range": 128,
    "connected_anonymous_ips_in_range": 940,
    "connected_total_in_range": 1068
  },
  "token_policy": {
    "access_token_ttl_minutes": 60,
    "refresh_token_ttl_days": 30,
    "online_source": "persisted_access_token_expiry"
  },
  "api_quality": {
    "request_count": 12000,
    "success_count": 11700,
    "client_error_count": 220,
    "server_error_count": 60,
    "incomplete_count": 20,
    "success_rate_percent": 97.5,
    "client_error_rate_percent": 1.83,
    "server_error_rate_percent": 0.5,
    "error_rate_percent": 2.5,
    "requests_per_minute": 4.1667,
    "response_bytes": 9876543,
    "latency_sample_count": 11980,
    "avg_latency_ms": 42.1,
    "p50_latency_ms": 18.0,
    "p95_latency_ms": 110.0,
    "p99_latency_ms": 280.0
  },
  "top_endpoints": [
    {
      "method": "GET",
      "path": "/api/stocks/2330.TW",
      "request_count": 1600,
      "server_error_count": 3,
      "incomplete_count": 0,
      "server_error_rate_percent": 0.19,
      "avg_latency_ms": 35.2,
      "p95_latency_ms": 92.0
    }
  ],
  "telemetry": {
    "latest_access_log_at": "2026-07-30T08:29:59Z",
    "ingestion_lag_seconds": 1.0,
    "access_log_writer_running": true,
    "access_log_queue_depth": 2,
    "access_log_queue_capacity": 10000
  }
}

API 品質只統計所選日期範圍內、且不晚於 generated_at 的 api_access_log:

  • 2xx / 3xx → success
  • 4xx → client error
  • 5xx → server error
  • status_code IS NULL → incomplete
  • 沒有 request 時 rate 與 RPM 為 0,latency percentile 與 average 為 null
  • error_rate_percent 包含 client error、server error 與 incomplete

telemetry.latest_access_log_at 是全表截至 generated_at 的最新 log,用來計算 ingestion lag,不受日期範圍限制。writer 未啟動時 access_log_writer_running=false,queue 數值為 0。