跳轉到

Recap API — 前端 / APP 整合指南

本文件面向前端 / iOS / Android 整合工程師。API 的權威文件在 api-reference.md §32A,本頁提供整合視角:範例 payload、error 處理、edge case、以及 mock/stub 建議。

1. 快速說明

Cortex 的盤後 recap 涵蓋三個維度 × 三個時間粒度,共 9 種組合,全部透過統一的 /api/recaps 路由存取。

維度 dimension 意義 典型 UI 位置
technical 技術面(價量結構、突破、均線、族群輪動) 技術分析 tab
chip 籌碼面(三大法人、融資融券、資金輪動) 籌碼分析 tab
orchestrator 跨維度整合(雙重確認個股 + 背離) 首頁總覽 / 推播
時間粒度 period_type 觸發時機 典型 UI
daily 每日盤後產出 每日快訊
weekly 當週最後交易日產出 週報
monthly 當月最後交易日產出 月報

首頁建議入口:Market Pulse

首頁若只需要快速告訴使用者「今天技術面與籌碼面狀況」,優先使用 Pulse API,而不是完整 recap:

GET /api/market-pulse/latest?market=taiwan
GET /api/market-pulse/{trade_date}?market=taiwan

Pulse response 會同時包含 technical_pulsechip_pulse,並用同一個 source_trade_date,避免首頁兩張卡片資料日期不同。前端顯示資料日期時請使用 top-level data_status.source_trade_date/latest 會回傳技術面與籌碼面都有資料的最新共同日期,可能早於單獨 technical/chip Pulse 的 latest 日期。headlineheadline_parts 均為規則式產生,不依賴 LLM;前端可直接顯示 headline,或用 headline_parts 拆成 chips。

每日大盤資訊入口:Daily Market

如果首頁或 dashboard 需要顯示 TAIEX、成交量、成交值、大盤融資餘額、外資台指期空單、三大法人買賣超、漲跌家數、均線狀態、量能結構與 regime,請使用統一 Daily Market API:

GET /api/daily-market/latest?market=taiwan
GET /api/daily-market/{trade_date}?market=taiwan

/api/daily-market 會保留每個市場層級欄位的 sourcedatestatus。沒有官方或市場層級資料時,數值會是 null,狀態會是 pending_source / no_data / source_error,前端不可把 null 當成 0 顯示。大盤融資餘額與外資期貨空單來自 official market-chip source,不是由個股 margin_balance 聚合。

前端常用欄位:

  • index.closeindex.change_pointsindex.change_percent:官方大盤指數漲跌。
  • index.previous_day_change.directionup / down / flat,用來直接判斷相較前一個可用交易日是漲或跌。
  • index.moving_averages.ma5 / ma20 / ma60:benchmark 5 / 20 / 60 日均線。
  • index.key_ma_state:關鍵均線狀態,包含 ma_alignmentprice_positionabove_ma5above_ma20above_ma60 與站上均線天數。
  • turnover.trade_value_amount:官方大盤成交值。
  • turnover.previous_day_change.trade_value_change_amount:官方大盤成交值相較前一個可用交易日的差異。
  • turnover.previous_day_change.directionup / down / flat,用來直接判斷成交值放大或收斂。
  • turnover.volume_structure:benchmark 量能結構,包含 current_volumevolume_ma_5avg_volume_20volume_ratiovolume_status
  • institutional.streaks.foreign|trust|dealer|total:外資、投信、自營與三大法人合計的連續買賣超方向與天數。
  • futures.foreign_tx_futures_position_change.foreign_tx_futures_net_short_oi_change:外資台指期淨空單相較前一個可用市場期貨資料日的變化。

均線與量能目前使用 daily_technical_snapshot^TWII benchmark row。若缺資料會回 status: "no_data",前端不可 fallback ETF proxy 或把 ETF proxy 誤標成官方加權指數技術線。

前一日指數差異、成交值差異與外資期貨部位變化有 stale gap 保護:若前一筆資料距離目前日期超過 10 個 calendar days,change 欄位會是 null 並標 status: "no_data",前端不可拿舊資料自行補差值。

認證

目前無需認證(與既有 /api/technical-recap/api/chip-recap 一致)。

Base URL

  • Local dev:http://localhost:8000
  • Production:TBD(請向後端團隊索取)

2. 兩個主要 endpoint

2.1 GET /api/recaps/{dimension}/{period_type}/latest

取該 dimension × period_type 的最新一筆 recap。前端常見用途:首頁開啟時載入最新週 / 月報。

Path params | 欄位 | 類型 | 說明 | |---|---|---| | dimension | "technical" \| "chip" \| "orchestrator" | 維度 | | period_type | "daily" \| "weekly" \| "monthly" | 時間粒度 |

Query params | 欄位 | 必填 | 預設 | 說明 | |---|---|---|---| | market | 否 | "taiwan" | 市場代碼 |

Response:回傳單一 recap 物件(結構依 dimension 不同,見 §3)

Errors | Code | 觸發條件 | |---|---| | 400 | dimension / period_type 不在允許清單 | | 404 | 該組合目前尚無任何資料(pipeline 尚未產出 / 回填未完成) |

2.2 GET /api/recaps/{dimension}/{period_type}/{requested_date}

取指定日期的 recap。

Path params 同上,新增: | 欄位 | 類型 | 說明 | |---|---|---| | requested_date | YYYY-MM-DD | Daily:查詢日;Weekly/Monthlyperiod_end_date |

語意差異(重要!)

dimension / period_type requested_date 語意 找不到會怎樣
technical/dailychip/daily 查詢日;若當日無資料(非交易日或 pipeline 未跑)→ 自動 fallback 到 ≤ requested_date 的最新交易日 仍找不到才回 404
technical/weeklychip/weekly*/monthly 必須精準等於 period_end_date(該週/月最後交易日) 直接 404
orchestrator/* 必須精準等於該 period_type 的 period_end_date 直接 404

實作提醒:前端不需要自己算「哪天是週最後交易日」—— 用 /latest endpoint 取,response 裡的 period_end_date 就是該週 key,下次帶回這個值即可。

Errors:同 /latest,再加 422requested_date 格式錯誤)。


3. 各 dimension 的 Response Schema

3.1 Technical Daily Response

GET /api/recaps/technical/daily/{date}

{
  "period_type": "daily",
  "trade_date": "2026-04-17",
  "market": "taiwan",
  "n_stocks": 1000,
  "advancers": 612,
  "decliners": 358,
  "breakout_count": 42,
  "breakdown_count": 18,
  "summary_json": {
    "headline": "AI 類股續強,大盤收紅 0.8%",
    "market_summary": "...",
    "breadth_summary": "...",
    "group_summary": "...",
    "stock_summary": "...",
    "risks": [
      {"text": "外資買超收斂", "evidence": "foreign_net_value=10B; prev=35B"}
    ],
    "risk_summary": "外資買超收斂"
  },
  "requested_trade_date": "2026-04-18",
  "source_trade_date": "2026-04-17",
  "is_fallback": true,
  "created_at": "...",
  "updated_at": "..."
}
欄位 類型 說明
n_stocks number 當日有效樣本數
advancers / decliners number 上漲 / 下跌家數
breakout_count / breakdown_count number 技術突破 / 跌破家數
summary_json.headline string ≤25 字標題(UI 卡片標題適用)
summary_json.market_summary string 市場結構敘述(1 段)
summary_json.breadth_summary string 廣度與內部強弱
summary_json.group_summary string 族群輪動
summary_json.stock_summary string 個股事件
summary_json.risks Array<{text, evidence}> 結構化風險清單(可能為空陣列)
summary_json.risk_summary string (Legacy)risks 文字串接,供未升級的 client 使用
is_fallback boolean true = requested_trade_date ≠ source_trade_date(回退到最近交易日)

3.2 Technical Weekly / Monthly Response

GET /api/recaps/technical/weekly/{period_end_date}
GET /api/recaps/technical/monthly/{period_end_date}

{
  "period_type": "weekly",
  "period_start_date": "2026-04-13",
  "period_end_date": "2026-04-17",
  "market": "taiwan",
  "n_sessions": 5,
  "n_stocks": 1000,
  "advancers": 2800,
  "decliners": 1650,
  "breakout_count": 120,
  "breakdown_count": 58,
  "summary_json": {
    "headline": "本週 AI 供應鏈同步擴散",
    "market_summary": "...",
    "breadth_summary": "...",
    "group_summary": "...",
    "stock_summary": "...",
    "period_summary": "週內連續三日創新高...",
    "risks": [...],
    "risk_summary": "..."
  },
  "created_at": "...",
  "updated_at": "..."
}

與 daily 的差異: - 欄位 trade_dateperiod_start_date + period_end_date - 新增 n_sessions(週/月內的交易日數,週通常 5,月通常 ~21) - 新增 summary_json.period_summary(跨日延續性觀察,daily 沒有) - 沒有 is_fallback / source_trade_date / requested_trade_date(weekly/monthly 精準匹配)

3.3 Chip Daily Response

GET /api/recaps/chip/daily/{date}

{
  "period_type": "daily",
  "trade_date": "2026-04-17",
  "market": "taiwan",
  "n_stocks": 1000,
  "total_foreign_net_value": 15600000000,
  "total_trust_net_value": 3200000000,
  "total_dealer_net_value": -800000000,
  "total_market_net_value": 18000000000,
  "total_margin_change_value": 1968695000,
  "total_short_change_value": null,
  "summary_json": {
    "market_context": {
      "official_market_margin_balance_amount": 329543807000,
      "official_market_margin_change_amount": 1968695000,
      "official_market_margin_status": "available",
      "foreign_tx_futures_short_oi": 52279,
      "foreign_tx_futures_net_short_oi": 43256,
      "foreign_tx_futures_status": "available"
    },
    "group_flow_summary": { "...": "..." },
    "stock_event_summary": { "...": "..." },
    "top_bullish_stocks": [
      {
        "ticker": "2330.TW",
        "name": "台積電",
        "group_name": "AI 伺服器",
        "total_net_value": 11000000,
        "foreign_net_value": 10000000,
        "trust_net_value": 2000000,
        "dealer_net_value": -1000000,
        "flow_score": 11000000,
        "reasons": ["foreign_net_buy", "trust_continuous_buy"],
        "dominant_actor": "foreign"
      }
    ],
    "top_bearish_stocks": [
      {
        "ticker": "2454.TW",
        "name": "聯發科",
        "group_name": "5G",
        "total_net_value": -8250000,
        "foreign_net_value": -7500000,
        "trust_net_value": -1500000,
        "dealer_net_value": 750000,
        "flow_score": -8250000,
        "reasons": ["foreign_net_sell", "trust_continuous_sell"],
        "dominant_actor": "foreign"
      }
    ],
    "risk_signals": []
  },
  "requested_trade_date": "2026-04-17",
  "source_trade_date": "2026-04-17",
  "is_fallback": false,
  "created_at": "...",
  "updated_at": "..."
}
欄位 類型 說明
total_foreign_net_value number (TWD) 外資當日淨買賣超金額(正=買超)
total_trust_net_value number (TWD) 投信淨額
total_dealer_net_value number (TWD) 自營商淨額
total_market_net_value number (TWD) 三大法人合計
total_margin_change_value number (TWD) | null 官方/市場層級融資金額變動;沒有 summary_json.market_context.official_market_margin_status="available" 時為 null,不得視為 0
total_short_change_value number (TWD) | null 官方/市場層級融券金額變動;目前沒有官方 TWD source 時為 null
summary_json.market_context.official_market_margin_balance_amount number (TWD) | null 大盤融資餘額金額
summary_json.market_context.official_market_margin_change_amount number (TWD) | 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
summary_json.top_bullish_stocks InstitutionalStockFlowItem[] 法人合計淨買超前 10 大股票,已依 total_net_value 由大到小排序;金額相同時依 ticker 穩定排序
summary_json.top_bearish_stocks InstitutionalStockFlowItem[] 法人合計淨賣超前 10 大股票,已依 total_net_value 由小到大排序;金額相同時依 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 排序

數字規模:原始單位為 TWD,常見量級為百億 ~ 千億。UI 顯示建議轉換為億元或 B/M 縮寫。

InstitutionalStockFlowItem:

type 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
  reasons: string[]
  dominant_actor: "foreign" | "trust" | "dealer" | "mixed"
}

OwnershipChangeRankingItem:

type OwnershipChangeRankingItem = {
  ticker: string
  name: string | null
  current_ratio: number | null
  change: number
  ownership_data_date: string | null
  ownership_data_age_days: number | null
  levels: string[]
}

Ownership 排行是從同日 daily_chip_snapshot 的 TDCC ownership 欄位即時計算,不需要前端自行合併 levels。_increases 清單只含正 change_decreases 清單只含負 change,item 內不再重複提供 direction

前端顯示「法人大買標的」請直接讀 summary_json.top_bullish_stocks; 顯示「法人大賣標的」請直接讀 summary_json.top_bearish_stocks。不要再從 stock_event_summary 的連買、連賣、買轉賣 buckets 自行合併與排序。

3.4 Chip Weekly / Monthly Response

與 daily 相同欄位結構,改為 period_start_date + period_end_date + n_sessions,不含 fallback 欄位。所有 total_*_net_value 為該期間的累計淨額

3.5 Orchestrator Response(所有 period_type 通用)

GET /api/recaps/orchestrator/{period_type}/{date} — 這是最有產品價值的 endpoint。

{
  "period_type": "weekly",
  "period_start_date": "2026-04-13",
  "period_end_date": "2026-04-17",
  "market": "taiwan",

  "headline": "本週 AI 擴散,2330.TW+2454.TW 雙重確認看多",
  "market_synthesis": "技術面突破搭配法人持續買超,AI 供應鏈呈現結構性強勢...",

  "dual_confirmation_narrative": "本週技術面與籌碼面同向確認的強勢個股:\n- 台積電 (2330.TW)(技術面 breakout|籌碼面 continuous_buy)\n- 聯發科 (2454.TW)(技術面 breakout|籌碼面 continuous_buy)\n\n族群層面雙重確認看多:\n- 半導體 (uptrend × inflow,代表股:2330.TW、2454.TW、3034.TW)",

  "dual_confirmation_stocks": [
    {
      "ticker": "2330.TW",
      "name": "台積電",
      "group_name": "半導體",
      "direction": "bullish",
      "technical_event_type": "breakout",
      "technical_score": 0.92,
      "chip_event_type": "continuous_buy",
      "chip_score": 580000000,
      "combined_score": 2.0
    },
    {
      "ticker": "3008.TW",
      "name": "大立光",
      "group_name": "光學元件",
      "direction": "bearish",
      "technical_event_type": "breakdown",
      "technical_score": 0.78,
      "chip_event_type": "buy_to_sell",
      "chip_score": 120000000,
      "combined_score": 1.73
    }
  ],

  "dual_confirmation_groups": [
    {
      "group_id": "semiconductor",
      "group_name": "半導體",
      "group_type": "industry",
      "direction": "bullish",
      "technical_trend_state": "uptrend",
      "technical_strength_score": 0.82,
      "chip_flow_direction": "inflow",
      "chip_flow_score": 12500000000,
      "representative_stocks": ["2330.TW", "2454.TW", "3034.TW"],
      "combined_score": 2.0
    }
  ],

  "divergence_stocks": [
    {
      "ticker": "2317.TW",
      "name": "鴻海",
      "group_name": "電子代工",
      "technical_direction": "bullish",
      "chip_direction": "bearish",
      "note": "技術 breakout 對 籌碼 continuous_sell"
    }
  ],
  "divergence_narrative": "本週鴻海 (2317.TW) 呈現價漲量縮搭配法人賣壓...",

  "risks": [
    {"text": "成交量收斂", "evidence": "week_volume=9.2T; prev=11.5T"},
    {"text": "融資擁擠", "evidence": "margin_change=+12%"}
  ],
  "parser_warnings": [],

  "synthesis_json": { "headline": "...", "market_synthesis": "...", "divergence_narrative": "...", "risks": [...], "parser_warnings": [] },
  "narrative_json": { "dual_confirmation_narrative": "..." },
  "technical_recap_json": { "stock_events": [...], "groups": [...] },
  "chip_recap_json": { "stock_events": [...], "groups": [...] },

  "model_name": "gemini-2.0-flash-exp",
  "provider_name": "gemini",
  "run_id": "01HKX...",
  "created_at": "...",
  "updated_at": "..."
}

technical_recap_json.stock_events[*].payload_json 現在會帶: - structured_signals: TA taxonomy,例如 ma_month_breakdownvwap_lossgap_down_breakdownbearish_reversal_pattern。 - ta_index_groups: MA line break、VWAP、normalized volatility、channel、gap/limit、oscillator、pattern grouped indexes。

Daily event buckets 可能新增 ma_month_reclaimma_month_breakdowngap_up_breakoutgap_down_breakdownlimit_up_strengthlimit_down_riskbullish_reversal_patternbearish_reversal_pattern。Weekly/monthly technical events 的 payload_json.period_ma_events 會提供該週/月 K 的 MA20/MA60 站上/跌破資訊。

🔑 核心產品邏輯:雙重確認(Dual Confirmation)

這是整個 recap 系統最高價值的輸出。個股同時在技術面與籌碼面出現同向訊號 = 高勝率訊號

欄位 資料來源 是否可能為空
dual_confirmation_stocks 程式模板(deterministic, 不經 LLM) 可能空陣列
dual_confirmation_groups 同上 可能空陣列
dual_confirmation_narrative 程式模板(逐一點名、零幻覺) 空陣列時回「本週無明顯雙重確認訊號」
divergence_stocks 程式模板(技術/籌碼方向相反) 可能空陣列
headline / market_synthesis / divergence_narrative / risks LLM 合成 LLM 失敗時為空字串,parser_warnings 會記錄

3.5.1 DualConfirmationStock 欄位

欄位 類型 說明
ticker string 股票代碼(含市場後綴,如 2330.TW006201.TWO
name string | null 股票中文名稱
group_name string | null 所屬族群(如「半導體」)
direction "bullish" \| "bearish" 共識方向
technical_event_type string 技術面事件類型(breakout / breakdown / trend_continuation_up / ...)
technical_score number 技術面事件原始分數(0~1)
chip_event_type string 籌碼面事件類型(continuous_buy / buy_to_sell / ...)
chip_score number 籌碼面事件原始金額
combined_score number (0~2) rank-normalized 綜合分數(chip TWD 量級無法壓倒 technical);UI 排序用這個

3.5.2 DualConfirmationGroup 欄位

欄位 類型 說明
group_id string 族群 ID
group_name string 族群名(顯示用)
group_type "industry" \| "sector" \| "concept" 族群分類
direction "bullish" \| "bearish" 共識方向
technical_trend_state "uptrend" \| "breakout" \| "rebound" \| "downtrend" \| "breakdown" \| "range" 族群技術趨勢
technical_strength_score number 族群技術強度
chip_flow_direction "inflow" \| "outflow" \| "neutral" 資金流向
chip_flow_score number 資金流量金額
representative_stocks string[] 代表股 ticker(最多 5 支)
combined_score number (0~2) 綜合分數

3.5.3 DivergenceStock 欄位

欄位 類型 說明
ticker / name / group_name string 同 DualConfirmationStock
technical_direction "bullish" \| "bearish" 技術面方向
chip_direction "bullish" \| "bearish" 籌碼面方向(與 technical 相反才進入此陣列)
note string 背離描述(如「技術 breakout 對 籌碼 continuous_sell」)

4. 整合範例

4.1 cURL

# 最新週報(orchestrator)
curl -s "http://localhost:8000/api/recaps/orchestrator/weekly/latest?market=taiwan" | jq

# 指定週末(如 2026-04-17 週五)
curl -s "http://localhost:8000/api/recaps/orchestrator/weekly/2026-04-17?market=taiwan" | jq '.headline, .dual_confirmation_stocks[].ticker'

# 今日技術面(daily 自動 fallback)
curl -s "http://localhost:8000/api/recaps/technical/daily/2026-04-19" | jq '.is_fallback, .source_trade_date'

4.2 TypeScript / React (fetch + SWR)

import useSWR from 'swr';

type Direction = 'bullish' | 'bearish';

type DualConfirmationStock = {
  ticker: string;
  name: string | null;
  group_name: string | null;
  direction: Direction;
  technical_event_type: string;
  technical_score: number;
  chip_event_type: string;
  chip_score: number;
  combined_score: number;
};

type DualConfirmationGroup = {
  group_id: string;
  group_name: string;
  group_type: 'industry' | 'sector' | 'concept';
  direction: Direction;
  technical_trend_state:
    | 'uptrend' | 'breakout' | 'rebound'
    | 'downtrend' | 'breakdown' | 'range';
  technical_strength_score: number;
  chip_flow_direction: 'inflow' | 'outflow' | 'neutral';
  chip_flow_score: number;
  representative_stocks: string[];
  combined_score: number;
};

type DivergenceStock = {
  ticker: string;
  name: string | null;
  group_name: string | null;
  technical_direction: Direction;
  chip_direction: Direction;
  note: string;
};

type RiskItem = { text: string; evidence: string };

type OrchestratorRecap = {
  period_type: 'daily' | 'weekly' | 'monthly';
  period_start_date: string;
  period_end_date: string;
  market: string;
  headline: string;
  market_synthesis: string;
  dual_confirmation_narrative: string;
  dual_confirmation_stocks: DualConfirmationStock[];
  dual_confirmation_groups: DualConfirmationGroup[];
  divergence_stocks: DivergenceStock[];
  divergence_narrative: string;
  risks: RiskItem[];
  parser_warnings: string[];
};

const fetcher = (url: string) => fetch(url).then(r => {
  if (!r.ok) throw new Error(`${r.status}`);
  return r.json();
});

export function WeeklyOrchestratorCard() {
  const { data, error, isLoading } = useSWR<OrchestratorRecap>(
    '/api/recaps/orchestrator/weekly/latest',
    fetcher,
  );

  if (isLoading) return <Skeleton />;
  if (error?.message === '404') return <EmptyState message="本週 recap 尚未產出" />;
  if (error) return <ErrorState />;

  const bullish = data!.dual_confirmation_stocks.filter(s => s.direction === 'bullish');
  const bearish = data!.dual_confirmation_stocks.filter(s => s.direction === 'bearish');

  return (
    <Card>
      <Headline>{data!.headline}</Headline>
      <Narrative>{data!.dual_confirmation_narrative}</Narrative>
      <StockList title="同向看多" stocks={bullish} />
      <StockList title="同向看空" stocks={bearish} />
    </Card>
  );
}

4.3 Swift (iOS, URLSession + Codable)

struct RiskItem: Codable {
    let text: String
    let evidence: String
}

struct DualConfirmationStock: Codable {
    let ticker: String
    let name: String?
    let groupName: String?
    let direction: String       // "bullish" | "bearish"
    let technicalEventType: String
    let technicalScore: Double
    let chipEventType: String
    let chipScore: Double
    let combinedScore: Double

    enum CodingKeys: String, CodingKey {
        case ticker, name, direction
        case groupName = "group_name"
        case technicalEventType = "technical_event_type"
        case technicalScore = "technical_score"
        case chipEventType = "chip_event_type"
        case chipScore = "chip_score"
        case combinedScore = "combined_score"
    }
}

struct DualConfirmationGroup: Codable {
    let groupId: String
    let groupName: String
    let groupType: String       // "industry" | "sector" | "concept"
    let direction: String       // "bullish" | "bearish"
    let technicalTrendState: String  // "uptrend" | "breakout" | "rebound" | "downtrend" | "breakdown" | "range"
    let technicalStrengthScore: Double
    let chipFlowDirection: String   // "inflow" | "outflow" | "neutral"
    let chipFlowScore: Double
    let representativeStocks: [String]
    let combinedScore: Double

    enum CodingKeys: String, CodingKey {
        case direction
        case groupId = "group_id"
        case groupName = "group_name"
        case groupType = "group_type"
        case technicalTrendState = "technical_trend_state"
        case technicalStrengthScore = "technical_strength_score"
        case chipFlowDirection = "chip_flow_direction"
        case chipFlowScore = "chip_flow_score"
        case representativeStocks = "representative_stocks"
        case combinedScore = "combined_score"
    }
}

struct DivergenceStock: Codable {
    let ticker: String
    let name: String?
    let groupName: String?
    let technicalDirection: String  // "bullish" | "bearish"
    let chipDirection: String
    let note: String

    enum CodingKeys: String, CodingKey {
        case ticker, name, note
        case groupName = "group_name"
        case technicalDirection = "technical_direction"
        case chipDirection = "chip_direction"
    }
}

struct OrchestratorRecap: Codable {
    let periodType: String
    let periodStartDate: String
    let periodEndDate: String
    let headline: String
    let marketSynthesis: String
    let dualConfirmationNarrative: String
    let dualConfirmationStocks: [DualConfirmationStock]
    let dualConfirmationGroups: [DualConfirmationGroup]
    let divergenceStocks: [DivergenceStock]
    let divergenceNarrative: String
    let risks: [RiskItem]
    let parserWarnings: [String]

    enum CodingKeys: String, CodingKey {
        case headline, risks
        case periodType = "period_type"
        case periodStartDate = "period_start_date"
        case periodEndDate = "period_end_date"
        case marketSynthesis = "market_synthesis"
        case dualConfirmationNarrative = "dual_confirmation_narrative"
        case dualConfirmationStocks = "dual_confirmation_stocks"
        case dualConfirmationGroups = "dual_confirmation_groups"
        case divergenceStocks = "divergence_stocks"
        case divergenceNarrative = "divergence_narrative"
        case parserWarnings = "parser_warnings"
    }
}

func fetchLatestWeeklyOrchestrator() async throws -> OrchestratorRecap {
    let url = URL(string: "\(baseURL)/api/recaps/orchestrator/weekly/latest")!
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResp = response as? HTTPURLResponse else { throw APIError.invalidResponse }
    if httpResp.statusCode == 404 { throw APIError.notYetAvailable }
    if httpResp.statusCode >= 400 { throw APIError.serverError(httpResp.statusCode) }
    return try JSONDecoder().decode(OrchestratorRecap.self, from: data)
}

4.4 Kotlin (Android, Retrofit + kotlinx.serialization)

@Serializable
data class RiskItem(
    val text: String,
    val evidence: String,
)

@Serializable
data class DualConfirmationStock(
    val ticker: String,
    val name: String?,
    @SerialName("group_name") val groupName: String?,
    val direction: String,   // "bullish" | "bearish"
    @SerialName("technical_event_type") val technicalEventType: String,
    @SerialName("technical_score") val technicalScore: Double,
    @SerialName("chip_event_type") val chipEventType: String,
    @SerialName("chip_score") val chipScore: Double,
    @SerialName("combined_score") val combinedScore: Double,
)

@Serializable
data class DualConfirmationGroup(
    @SerialName("group_id") val groupId: String,
    @SerialName("group_name") val groupName: String,
    @SerialName("group_type") val groupType: String,       // "industry" | "sector" | "concept"
    val direction: String,
    @SerialName("technical_trend_state") val technicalTrendState: String,  // "uptrend" | "breakout" | "rebound" | "downtrend" | "breakdown" | "range"
    @SerialName("technical_strength_score") val technicalStrengthScore: Double,
    @SerialName("chip_flow_direction") val chipFlowDirection: String,  // "inflow" | "outflow" | "neutral"
    @SerialName("chip_flow_score") val chipFlowScore: Double,
    @SerialName("representative_stocks") val representativeStocks: List<String>,
    @SerialName("combined_score") val combinedScore: Double,
)

@Serializable
data class DivergenceStock(
    val ticker: String,
    val name: String?,
    @SerialName("group_name") val groupName: String?,
    @SerialName("technical_direction") val technicalDirection: String,  // "bullish" | "bearish"
    @SerialName("chip_direction") val chipDirection: String,
    val note: String,
)

@Serializable
data class OrchestratorRecap(
    @SerialName("period_type") val periodType: String,
    @SerialName("period_start_date") val periodStartDate: String,
    @SerialName("period_end_date") val periodEndDate: String,
    val headline: String,
    @SerialName("market_synthesis") val marketSynthesis: String,
    @SerialName("dual_confirmation_narrative") val dualConfirmationNarrative: String,
    @SerialName("dual_confirmation_stocks") val dualConfirmationStocks: List<DualConfirmationStock>,
    @SerialName("dual_confirmation_groups") val dualConfirmationGroups: List<DualConfirmationGroup>,
    @SerialName("divergence_stocks") val divergenceStocks: List<DivergenceStock>,
    @SerialName("divergence_narrative") val divergenceNarrative: String,
    val risks: List<RiskItem>,
    @SerialName("parser_warnings") val parserWarnings: List<String>,
)

// Note: different dimensions return different shapes. Pick dimension-specific
// methods for compile-time type safety. TechnicalRecap / ChipRecap data
// classes follow the same pattern as OrchestratorRecap above — define per
// §3.1–§3.4 in this doc.
interface RecapApi {
    // Orchestrator (any period_type) → OrchestratorRecap
    @GET("api/recaps/orchestrator/{periodType}/latest")
    suspend fun getOrchestratorLatest(
        @Path("periodType") periodType: String,
        @Query("market") market: String = "taiwan",
    ): OrchestratorRecap

    @GET("api/recaps/orchestrator/{periodType}/{date}")
    suspend fun getOrchestratorByDate(
        @Path("periodType") periodType: String,
        @Path("date") date: String,            // YYYY-MM-DD
        @Query("market") market: String = "taiwan",
    ): OrchestratorRecap

    // Technical and chip return their own shapes; add parallel methods:
    // suspend fun getTechnicalLatest(...): TechnicalRecap
    // suspend fun getChipLatest(...): ChipRecap
    // If you prefer a single untyped method, return JsonElement and
    // branch on dimension in the caller.
}

5. 錯誤處理建議

HTTP 前端應該
200 正常渲染
400 當作 bug(參數組合非法,不應在 production 出現) — log 並 fallback 到預設頁
404 顯示空狀態「本週 recap 尚未產出」—— 不是錯誤,只是時機未到或尚未 backfill
422 requested_date 格式錯誤 —— 前端應在 client-side 驗證 YYYY-MM-DD 格式
5xx 伺服器錯誤 —— 顯示通用錯誤 + retry 按鈕

空狀態的三種情境

  1. 尚未到產出時機 — 例:週三呼叫 /api/recaps/orchestrator/weekly/latest 取到上週資料(正常),但若是新週首日還沒有新一週 → latest 仍然是上週(要提示用戶這是上週資料,看 period_end_date
  2. LLM 部分失敗headline / market_synthesis 為空字串,parser_warnings 非空 → 仍然可以顯示 dual_confirmation_narrative(這部分不經 LLM,不會失敗)
  3. 完全 404 — 該日期 pipeline 尚未跑,呼叫 /{date} 回 404 → 顯示「尚未產生」並建議切到 latest

6. UI / UX 設計建議

6.1 Orchestrator Weekly 首屏

┌─────────────────────────────────────────────┐
│ 本週 AI 擴散 2330.TW+2454.TW 雙重確認看多 [週] │ ← headline + period badge
├─────────────────────────────────────────────┤
│ 技術面突破搭配法人持續買超...(2-3 行)      │ ← market_synthesis 節錄
├─────────────────────────────────────────────┤
│ 🟢 同向確認看多 (2)                         │
│  ▸ 台積電 (2330.TW) breakout + continuous_buy│ ← dual_confirmation_stocks
│  ▸ 聯發科 (2454.TW) breakout + continuous_buy│   (direction=bullish)
├─────────────────────────────────────────────┤
│ 🔴 同向確認看空 (1)                         │
│  ▸ 大立光 (3008.TW) breakdown + buy_to_sell │   (direction=bearish)
├─────────────────────────────────────────────┤
│ ⚠️ 技術/籌碼背離 (1)                         │
│  ▸ 鴻海 (2317.TW) 技術看多 vs 籌碼看空      │ ← divergence_stocks
├─────────────────────────────────────────────┤
│ 風險:                                       │
│  · 成交量收斂                                │
│  · 融資擁擠                                  │
└─────────────────────────────────────────────┘

6.2 排序建議

dual_confirmation_stocks / dual_confirmation_groups 已依 combined_score DESC 排序,前端不需再排序。若要限制顯示數量,取前 N 個即可。

6.3 Ticker 點擊導頁

所有 ticker 字串包含市場後綴(上市 .TW、上櫃 .TWO,如 2330.TW006201.TWO)。若 UI 內部路由使用不帶後綴的形式,用 split('.')[0] 去除即可。

6.4 Direction 視覺對應

direction 色系 圖示建議
bullish 紅色系(台股慣例) ↑ / 🔺
bearish 綠色系(台股慣例) ↓ / 🔻

6.5 週/月粒度切換

前端常見需求是「週報 ↔ 月報 ↔ 每日推播」切換。三者 URL pattern 一致,只換 period_type path 段即可,response shape 幾乎相同(只差 period_summary 欄位月 vs 週內容不同,前端不需特判)。


7. 資料產出時機

period_type 觸發時機 延遲
daily 每日盤後 daily pipeline 跑完(約 15:30 TW 後) + orchestrator 合成 LLM 盤後 30 分鐘內
weekly 每週最後交易日(通常週五)盤後,跟 daily 同一輪 同上
monthly 每月最後交易日盤後 同上

Scheduler 會在對應條件達成時自動 dispatch,前端無需主動觸發。若要強制重算,需由後端 CLI:

python scripts/run_periodic_recap.py --period-type weekly --period-end-date 2026-04-17 --dimensions technical,chip,orchestrator


8. ⚠️ 目前已知限制(整合前必讀)

8.1 技術面週/月聚合正在重寫(PR-4)

目前版本(dev 最新)的技術面週/月 recap 實作是從已事件化的 daily_technical_* 彙整表聚合,這個做法存在聚合失真問題: - 週 breakout_count = SUM(日 breakout_count)(應為週線 breakout 事件數) - 週 trend_state = mode(5 個日 trend_state)(應為週 K 線上的 trend) - 族群 strength_score = mean(5 個日分數)(應為週 bar 重算)

前端影響:response schema 不變,欄位型別與意義不變;只是數值在 PR-4 合併後會有差異。前端可以按本文件 schema 開發,PR-4 合併後換到正確數字即可,不需改前端程式碼

8.2 籌碼面週/月大致正確

淨額 SUM 是籌碼資料的正確聚合方式,唯一會微調的是 streak_days(目前是日連續,PR-4 會改為週連續),對前端 UI 影響極小。

8.3 Orchestrator 雙重確認不受影響

dual_confirmation_stocks / dual_confirmation_groups / divergence_stocks 是在既有 periodic_*_stock_event 基礎上比對,邏輯正確;PR-4 只會讓上游事件更精準,但比對演算法不變。

8.4 /groups/stocks 子路由

本次 API 尚未提供 /api/recaps/{dim}/{period}/{date}/groups/stocks 子路由。若前端需要族群或個股清單,暫時從 orchestrator response 的 dual_confirmation_groups / dual_confirmation_stocks / divergence_stocks 取用,或等後續 PR 補上對稱路由。

8.5 歷史 backfill

Production DB 目前只有 2026-04-01 起的 daily recap 資料(週/月表是空的)。PR-4 完成後將進行 8 年 raw data backfill —— 屆時 latest endpoint 就會有穩定數據。


9. Legacy / 相容 endpoints

以下舊路由仍保留且可用(與新路由讀同一張 daily_* 表),但標為 deprecated,建議新專案直接用 /api/recaps/*

Legacy 對應新路由
GET /api/technical-recap/latest GET /api/recaps/technical/daily/latest
GET /api/technical-recap/{date} GET /api/recaps/technical/daily/{date}
GET /api/technical-recap/{date}/groups (暫無對應 unified 路由)
GET /api/technical-recap/{date}/stocks (暫無對應 unified 路由)
POST /api/technical-recap/{date}/analyze (暫無對應 unified 路由)
GET /api/chip-recap/* 對稱同上

舊路由 response schema 與 /api/recaps/{dim}/daily/* 完全一致,可無痛遷移。


10. 問題回報

  • API schema 疑問 → 後端團隊
  • UI / UX 討論 → Product 團隊
  • Bug / 資料錯誤 → GitHub issue 附 PR 連結 + 實際 response payload

最後更新:2026-04-20(配合 PR #133 Recap 上線 + PR-4 技術面聚合修正規劃)