跳轉到

Indicators 空回應契約 Integration Guide — Frontend

Backend branch: fixed/indicators-empty-when-adjusted-pending → cortex/dev Endpoint: GET /api/stocks/{ticker}/indicators(僅 latest=true,即預設) 生效時間:merge 後 cortex-api --reload 立即生效,無 migration、無需重啟 scheduler

⚠️ 這是 breaking change,且現有 consumer 會壞。 Cortex-Management 的個股頁 (src/app/(admin)/stocks/[ticker]/page.tsx:713)用 {indicatorData ? … : <Spin/>} 判斷,而 {} 在 JS 是 truthy → 會渲染空表格而非載入中;:275-279 的 query 沒有 refetchInterval,所以不會自動恢復。 後端優先進版,前端跟進;前端 issue 不作為後端 SIT/production 發版閘門。 部署新後端後,舊版前端可能出現上文的空表格, 且不會自動恢復;請安排 Cortex-Management#248 跟進處理。SIT 發版仍須符合後端自身的容量、migration、備份及資料驗證門檻,詳見 v0.24.0 SIT release runbook。

Consumer:讀 /indicators 的前端頁面(個股技術指標區塊)。 本次盤點 docs/ 未找到 mobile / SDK 消費此端點的紀錄,故只出這一份; 若有未登記的 consumer,請補一份對應檔案。


1. 欄位 diff 表

沒有任何欄位改名、新增或刪除。 這是純粹的 status code + payload 內容變更。

endpoint 欄位 型別 變動 語意
GET /{ticker}/indicators indicators object 值域擴大 新增「空物件 {}」這個合法值,代表該交易日的指標尚未就緒
GET /{ticker}/indicators trading_date string (YYYY-MM-DD) 語意擴充 原本只在有資料時出現;現在 indicators 為空時仍然給值,代表「答不出來的是哪一天」
GET /{ticker}/indicators ticker / timestamp string 不變 —

⚠️ indicators: {} 與 indicators: {"rsi": null} 是兩件不同的事,前端必須分開處理:

payload 意思 建議 UI
{}(空物件) 這一天的指標還沒算出來(adjusted OHLC 未補齊) 「資料準備中」
{"rsi": null, "ma": {"5": null}} 指標算過了,但該檔歷史長度不足以暖身(thin history) 該格顯示 —,其餘正常渲染

兩者都是 200。只判斷 Object.keys(indicators).length === 0 才能區分。

✅ 本次一併收緊了 ?fields=(見 §4-8):

fields 值 結果
省略/空字串/全空白 200,不篩選,回全部 31 個群組
rsi,macd 200,只回這兩個群組
含未知群組名(bogus) 422(原本會過濾出空 indicators)
大小寫不符(RSI) 422 — 群組名大小寫敏感、一律小寫,與回應中的鍵一致
尾逗號/重複名/空白填充(rsi,、rsi,rsi、rsi, macd) 200,正常篩選
只有分隔符(,,,、, ,) 422(同上,從另一側撞進同一個歧義)

因此 {} 只有一個意思:尚未就緒,不需要呼叫端額外判斷。


2. 行為改變對照表

情境 修前 修後
該檔 adjusted OHLC 尚未追上 raw D 503 {"error": {"code": "indicator data is temporarily unavailable: the adjusted OHLC series...", ...}} 200 {..., "trading_date": "<raw D>", "indicators": {}}
recompute fallback 算出的序列最後一天 < raw D 503(同上 envelope) 200 同上
cutover 狀態非 active 503 {"error": {"code": "...the indicator-v2 cutover is not active", ...}} 此 503 已永久消失(閘門連同狀態機在 PR2 範圍裁決中移除,源碼零殘留)
查無此 ticker / 完全無 OHLC 404 {"error": {"code": "No data available", ...}} 404(不變,未被併入空回應)
指標齊全 200 + 31 個群組 200 + 31 個群組(不變)
latest=false(series 模式) 不受影響 不受影響(不同 response model,無 indicators 欄位)

為什麼不再是 503:這個視窗每個交易日都會開一次——raw 收盤之後、adjusted 批次跑完之前。 撰稿當下(2026-08-19)dev 實測:2,410 檔中有 1,380 檔(57%)的最新交易日 adjusted 尚未補齊。 503 對前端的意思是「伺服器壞了,重試」,會觸發錯誤頁與告警;實際上它只是當天的正常階段。


3. TypeScript 完整 type diff

 export interface IndicatorsResponse {
   ticker: string;
   timestamp: string;
-  /** 這批指標所描述的交易日 */
+  /**
+   * 這筆回應所指的交易日。
+   * `indicators` 為空物件時仍會給值 —— 代表「我們答不出來的是這一天」。
+   */
   trading_date: string;
-  indicators: IndicatorGroups;
+  /** 空物件 = 該交易日指標尚未就緒(非錯誤)。見 isIndicatorsPending()。 */
+  indicators: IndicatorGroups | Record<string, never>;
 }

+/**
+ * 指標尚未就緒(adjusted OHLC 未補齊)。
+ *
+ * 兩種「看起來空」要分清楚:
+ *   - `{}`                → 尚未就緒(本函式回 true)
+ *   - `{rsi: null, ...}`  → 算過了,但歷史長度不足以暖身
+ *
+ * 未知的 fields 值會回 422,不會退化成空 `{}`,所以這裡不需要額外參數。
+ */
+export function isIndicatorsPending(r: IndicatorsResponse): boolean {
+  return Object.keys(r.indicators).length === 0;
+}

呼叫端:

 async function loadIndicators(ticker: string) {
   const res = await fetch(`/api/stocks/${ticker}/indicators`);
-  if (res.status === 503) {
-    // 舊:把 adjusted 未補齊當成服務中斷
-    showServiceUnavailable();
-    scheduleRetryWithBackoff();
-    return;
-  }
   if (res.status === 404) {
     showNoSuchTicker();
     return;
   }
   if (!res.ok) {
     showError();
     return;
   }
   const data: IndicatorsResponse = await res.json();
+  if (isIndicatorsPending(data)) {
+    // 新:正常的「尚未就緒」,帶著日期,不是錯誤
+    showPreparing(data.trading_date);
+    return;
+  }
   render(data);
 }

4. 七個情境的真實 response

以下 JSON 由真實 handler對 dev 真實資料(2026-08-19)執行後擷取,非手寫。

成功回應的擷取方式:以真實 AsyncSession 連 dev postgres、經 ASGI 送出真實 HTTP 請求, 刻意不啟動 api.main 的 lifespan(它會寫入 theme job 與 access log,未取得 live DB 寫入核可)。

錯誤回應必須經過 api.main,因為它的全域 handler 會把 HTTPException 重塑成 {"error": {code, message, details}} envelope。繞過它取得的錯誤範例會是 {"detail": "..."}——這條 GET /indicators 的 route-level rejection 不會送出那個形狀。

⚠️ 這句只描述本端點自己拋出的 HTTPException,不能推廣到整個 API。 實測(2026-08-19,live cortex-api)至少有三處確實會送 {"detail": ...}:

來源 回應
未知路由(routing 層,不經 api.main handler) 404 {"detail":"Not Found"}
method 不符 405 {"detail":"Method Not Allowed"}
api/routes/stock_rules.py:245(端點自行回 JSONResponse) 404 {"detail":"..."}

判斷 body 形狀時請以「誰拒絕的」為準,不要以 API 為單位假設。 完整分類見 docs/api-reference.md 的「通用 Response 格式」。

下方錯誤範例皆取自 live cortex-api,未截短——code 與 message 對字串 detail 必為完全相同的值,只截短其中一欄的範例就不可能是真實回應。

4-1 adjusted 尚未補齊 —— 本次改動的主場景

curl -s "$API/api/stocks/2330.TW/indicators"
{
  "ticker": "2330.TW",
  "timestamp": "2026-08-19T07:27:54.178205",
  "trading_date": "2026-08-19",
  "indicators": {}
}

修前這一筆是 503。trading_date 給的是 raw D(2026-08-19),不是最後一筆有指標的日子。

4-2 adjusted 尚未補齊(ETF,同一條路徑)

curl -s "$API/api/stocks/00400A.TW/indicators"
{
  "ticker": "00400A.TW",
  "timestamp": "2026-08-19T07:27:54.179922",
  "trading_date": "2026-08-19",
  "indicators": {}
}

4-3 指標齊全(happy path)

curl -s "$API/api/stocks/1569.TWO/indicators"
{
  "ticker": "1569.TWO",
  "timestamp": "2026-08-19T07:27:54.306309",
  "trading_date": "2026-08-18",
  "indicators": {
    "ma": {
      "5": 42.72,
      "10": 42.72,
      "20": 41.5875,
      "50": 45.01,
      "60": 46.18333333333333,
      "100": 49.480999999999995,
      "120": 49.93416666666667,
      "200": 52.516999999999996,
      "240": 53.880833333333335
    },
    "ema": {
      "5": 43.166576459750964,
      "10": 42.61527185814526,
      "20": 42.69524993101685,
      "50": 44.96847626519581,
      "60": 45.678475569029125,
      "100": 47.86363377350866,
      "120": 48.66105198064016,
      "200": 50.519157997059345,
      "240": 50.82973391016513
    },
    "rsi": 55.43587275910104
  }
    // ... 其餘群組省略
  }
}

完整回應含 31 個指標群組。

4-4 歷史長度不足 —— 群組在、值是 null(不是空物件)

curl -s "$API/api/stocks/020012.TW/indicators"
{
  "ticker": "020012.TW",
  "timestamp": "2026-08-19T07:27:54.432026",
  "trading_date": "2026-08-18",
  "indicators": {
    "ma": {
      "5": null,
      "10": null,
      "20": null,
      "50": null,
      "60": null,
      "100": null,
      "120": null,
      "200": null,
      "240": null
    },
    "ema": {
      "5": 22.386666666666667,
      "10": 22.274545454545454,
      "20": 22.210476190476193,
      "50": 22.16901960784314,
      "60": 22.16426229508197,
      "100": 22.154653465346534,
      "120": 22.15223140495868,
      "200": 22.1473631840796,
      "240": 22.146141078838177
    },
    "rsi": null
  }
    // ... 其餘群組省略
  }
}

注意這裡 indicators 非空(31 個群組都在),只是值為 null。 前端若用「有沒有值」判斷就會誤判成尚未就緒——必須看 Object.keys().length。

4-5 查無此 ticker(維持 404,未被併入空回應)

curl -s "$API/api/stocks/ZZZZ9.TW/indicators"
{
  "error": {
    "code": "No data available",
    "message": "No data available",
    "details": {}
  }
}

4-6 欄位過濾

curl -s "$API/api/stocks/1569.TWO/indicators?fields=rsi,macd"
{
  "ticker": "1569.TWO",
  "timestamp": "2026-08-19T07:27:54.376151",
  "trading_date": "2026-08-18",
  "indicators": {
    "rsi": 55.43587275910104,
    "macd": {
      "value": -0.5409571909680153,
      "signal": -1.116715153323936,
      "histogram": 0.5757579623559206
    }
  }
}

4-7 series 模式(不受本次改動影響)

curl -s "$API/api/stocks/2330.TW/indicators?latest=false&days=3"

回應是 IndicatorsSeriesResponse(ticker / days / total / data[]), 沒有 indicators 也沒有 trading_date,本次契約變更與它無關。

4-8 未知的 fields 值 —— 現在是 422

curl -s "$API/api/stocks/1569.TWO/indicators?fields=bogus"
{
  "error": {
    "code": "unknown indicator field(s): bogus. Valid groups: adx, aroon, atr, bollinger, cci, chop, di, donchian, elder_ray, ema, ichimoku, kd, keltner, ma, macd, mfi, obv, qqe, rsi, supertrend, szo, tii, volume_ma, vortex, vsa, vwap, wavetrend, weis_wave, willr, wvixf",
    "message": "unknown indicator field(s): bogus. Valid groups: adx, aroon, atr, bollinger, cci, chop, di, donchian, elder_ray, ema, ichimoku, kd, keltner, ma, macd, mfi, obv, qqe, rsi, supertrend, szo, tii, volume_ma, vortex, vsa, vwap, wavetrend, weis_wave, willr, wvixf",
    "details": {}
  }
}

改動前這一筆會回 200 + 空 indicators——與 §4-1 的「尚未就緒」body 形狀完全一樣、 意思完全相反,而且呼叫端無法從 body 分辨(知道自己送了 fields 只能讓你選擇 「不判 pending」,不能判斷真正狀態,所以帶 fields 的真 pending 反而會被誤當成 正常空結果)。因此本次一併收緊:未知群組名直接 422,{} 只保留一個意思。

只有分隔符的值同樣是 422:

curl -s "$API/api/stocks/1569.TWO/indicators?fields=,,,"
{
  "error": {
    "code": "the fields parameter names no indicator group. Valid groups: adx, aroon, atr, bollinger, cci, chop, di, donchian, elder_ray, ema, ichimoku, kd, keltner, ma, macd, mfi, obv, qqe, rsi, supertrend, szo, tii, volume_ma, vortex, vsa, vwap, wavetrend, weis_wave, willr, wvixf",
    "message": "the fields parameter names no indicator group. Valid groups: adx, aroon, atr, bollinger, cci, chop, di, donchian, elder_ray, ema, ichimoku, kd, keltner, ma, macd, mfi, obv, qqe, rsi, supertrend, szo, tii, volume_ma, vortex, vsa, vwap, wavetrend, weis_wave, willr, wvixf",
    "details": {}
  }
}

",,," 非空白,所以不會走「省略 = 不篩選」那條;但它一個群組也沒指名, 放著不管就會過濾出 {}——從另一側撞進同一個歧義。空字串與全空白則維持 「不篩選」語意,回全部群組。


5. 前端遷移 checklist

  • [ ] grep 503:刪除針對 adjusted-lag 的 503 分支與其 retry/backoff。 不要把這支端點的 503 處理整個拿掉——應用層不再回 503,但 Cloudflare/ 反向代理在 origin 掛掉或過載時仍會回 503。確認刪完之後還有一條通用 !res.ok 錯誤路徑接得住
  • [ ] grep cutover:若有針對 the indicator-v2 cutover is not active 的文案或分支,一併刪除
  • [ ] 新增 isIndicatorsPending() helper(見 §3),用 Object.keys().length === 0,不要用 !indicators 或 isEmpty 之類會把 {rsi: null} 一起吃掉的寫法
  • [ ] 在 render 前插入 pending 分支,顯示「資料準備中」+ trading_date,不觸發錯誤頁
  • [ ] 移除針對 adjusted-lag 的 retry/backoff 與相關告警規則(每個交易日都會誤觸)
  • [ ] 若有使用 ?fields=:確認送出的群組名稱都是有效的——未知名稱回 422, 只有分隔符(,,,)也回 422(兩者原本都會靜默回空 indicators)。 若你的 UI 允許使用者清空選擇,送空字串(不篩選)或乾脆別帶這個參數, 不要送出 ","。有效群組見 INDICATOR_GROUP_NAMES;422 的 detail 也會列出完整清單
  • [ ] pending 時做有界輪詢(例如 30-60s 間隔並設上限),非空即停止。 backend 的 Cache-Control: no-store 只約束 HTTP cache,不會讓 React Query 已接受的成功結果自動重抓
  • [ ] 確認 404 分支仍在且與 pending 分開(「查無此股」vs「今天還沒算好」是兩種文案)
  • [ ] 更新 IndicatorsResponse type 定義(見 §3 diff)
  • [ ] 檢查快取層:空 indicators 不應被長期快取,adjusted 補齊後應能拿到新結果
  • [ ] 若有 e2e/mock fixture 押 503,改成 200 + 空 indicators

6. 參考

  • API 規格:docs/api-reference.md — 「exact-D 契約與『尚未就緒』語意」
  • OpenAPI:docs/assets/openapi.json(本次已重生,/indicators 只剩 200/422)
  • 裁決日期:2026-08-19(owner ruling:「不應該回 503 而是應該回空就好了」)