Cortex Investment Agent API — 前端串接技術文件
Base URL:
http://<host>:<port>API Version: v1 Protocol: REST (JSON) + SSE Authentication: Bearer Token (JWT)
目錄
- 通用說明
- 認證 Auth
- 會員 Members 3B. 管理端會員 Admin Members
- 訂閱 Subscriptions
- 股票資料 Stocks
- 技術指標 Indicators
- 三大法人 Institutional 7b. 八大行庫/券商分點 Participant Flows
- 融資融券 Margin 8B. 台股逐檔當沖統計 Day Trading
- 股權分散表 Ownership
- 基本面 Fundamentals
- 策略訊號 Signals
- 每日分析 Daily Analysis
- 訊號回測 Signal Backtest
- MAS 多代理分析
- Agent Payloads
- 自選股 Watchlist
- 投資組合 Portfolio
- 族群 Groups 18B. 統一個股清單 Stock Lists 18C. 單一題材量價分析 Theme Agent
- 策略規則 Rules
- 個股規則 Stock Rules
- 黑天鵝階段 Black Swan
- 供應鏈分析 Supply Chain
- 更新紀錄 Updates
- Health Check
- 錯誤處理
- Observability — Access Log 26B. Server Metrics
- 統一策略 Strategy
- 原物料 Commodities
- MAS Execution Trace
- Data Status 30B. Sync Status
- 籌碼面 Recap
- 技術面 Recap 32B. 首頁 Market Pulse 32C. 統一 Daily Market 32D. 全市場多面向日報分析
- 用戶反饋 Feedback
- 管理端反饋 Admin Feedback
- 使用者群組 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 對 livecortex-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 對 livecortex-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 的
@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,因此缺Authorizationheader 打需登入的端點會由 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的會員讀保留的adminpermission set;其他會員讀目前生效的 tier(member_entitlements,無則 free)。目前只有adminpermission 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。例外是缺少必填參數(Authorizationheader、持股串流的tickers):FastAPI 在進入權限檢查前就回 422。 - SSE 端點在連線時檢查(403 為一般 HTTP 錯誤、串流不會開始),之後每輪輪詢連同 token 一起重新檢查;連線中失去權限(例如被取消 admin、tier 被關閉)時送出
unauthorized事件並結束。 /api/members/me、/api/auth/me回的tier.features是會員所屬 tier 的設定,不含 admin 的保留權限;前端判斷可否使用即時功能時,請以這些端點實際回 403FEATURE_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 registrysesi_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_codevalues above (e.g.accumulation,chip_inst_flow,fin_growth_quality) are illustrative placeholders and are not guaranteed to resolve via theconfig/sesi_names.yamlregistry; thenameshown for them is representative only. In real responses each code is a live SESI code whosenamecomes 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,evidenceunsupported_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.yamladvice:區塊。
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/totalexpected_cash_dividend(不進 realized/total)unrealized_pnl/total_pnl/capital_employed/return_pct/equity_multipleopened_lots_count/closed_lots_countdata_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-coveragewrapper 皆會鎖該題材列 (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 鎖列後才判斷):
- 題材名稱不是單一 URL path segment(含
/、\、控制字元,或正好是./..)→ 跳過,skipped[].reason = "題材名稱不是合法的路徑片段"。 檔名本身不可能含/,但..md的 stem 是.;這種名稱一旦建立就再也 無法被任何路由讀取/編輯/刪除,因此 fail closed,不建立 is_blocked=true(曾被刪除)→ 跳過,skipped[].reason = "已刪除,不再接入"- 新表沒有這個題材 → 建立,
source="coverage"、is_edited=false,成員source="coverage",角色取自題材頁 - 已存在且(
source="manual"或is_edited=true)→ 跳過,skipped[].reason = "手動維護中" - 已存在且是未編輯的 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,對每個成員代號依序嘗試:
- 先查
taiwan.stocks.listing_type(一次查詢,涵蓋大多數情況) - 查無時,才在
begin_nested()savepoint 內以 try/except 呼叫resolve_durable_listing_types解析歷史掛牌類別;savepoint 讓該呼叫失敗 (例如非 PostgreSQL 環境)時不會拖垮整個 transaction - 仍解不出來 → 該成員被跳過,不寫入(
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 snapshot422—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_20d58 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')JOINstock_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的^TWIIbenchmark 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 或大於 configmax_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.closebaseline_close_price:current_close_price前一筆有效stock_ohlc.closeprice_delta:current_close_price - baseline_close_priceprice_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不再 inheritETFRecapItem。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對應 responsestart_date到end_date之間最新可取得的 OHLC 收盤價,baseline_close_price對應同一區間內最早可取得的 OHLC 收盤價。成分股價格查詢與/changes一樣,優先使用constituent_tickerexact match;台股股票 exact miss 時可用同raw_code的.TW/.TWOfallback。可取得代表close不是NULL/NaN。price_delta = current_close_price - baseline_close_price,price_change_pct = price_delta / baseline_close_price * 100。 - 每檔
stocks[]也會包含同格式的成分股價格 summary,並使用同一個 responsestart_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 |
- 不提供
daysquery,也沒有 60 天上限。 - 固定讀取該 ETF 全部
status='success' AND fetched_count > 0,且fetched_count等於該 ETF/date 實際持久化總筆數的完整快照;筆數不一致的重抓殘留快照會被排除,排除數量由excluded_snapshot_count揭露。 - 僅納入
asset_type='stock'、quantity_unit='shares'且有.TW/.TWOcanonical 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/.TWOsuffix 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_tickerpath 格式不合法,有兩種 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 NULLaccess_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→ success4xx→ client error5xx→ server errorstatus_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。