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,livecortex-api)至少有三處確實會送{"detail": ...}:
來源 回應 未知路由(routing 層,不經 api.mainhandler)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「今天還沒算好」是兩種文案)
- [ ] 更新
IndicatorsResponsetype 定義(見 §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 而是應該回空就好了」)