跳轉到

持倉名稱 + 批次績效 Integration Guide — Frontend

Backend branch: feat/portfolio-holdings-name-batch-performance → cortex/dev API base: SIT(會員 Bearer token 必要) 生效時間:merge 後 cortex-api --reload 立即生效,無 migration

Consumer:cortex 前端「現有持倉」頁。本次是純效能改造:該頁原本要為每檔持股各打一次 GET /api/stocks/{ticker}(只為了拿名稱)+各打一次 GET /api/portfolio/performance?ticker=X (每次都把整個投資組合重算一遍)。改造後名稱直接跟在 holdings row 上,績效改成一次批次呼叫:批次端點內部只跑一次 portfolio metrics、一次最新價查詢與一次 baseline 查詢(每個查詢各自對每個市場/country 跑一次),查詢次數只隨市場數變動、不隨檔數增加。


1. 欄位改名對照表(Breaking)

無。本次是純 additive,既有欄位的名稱、型別、語意一律未動,既有的 ?ticker= / ?type= / 不帶參數三種 request mode 也完全不變。

2. 新欄位(additive)

Endpoint 新欄位 型別 語意
GET /api/portfolio/holdings name string \| null 該 ticker 在 {country}.stocks.name 的顯示名稱;stocks 查無該檔或名稱為 NULL 時 null
GET /api/portfolio/performance tickers(query) string 逗號分隔的批次代碼清單;與 ticker、type 互斥
GET /api/portfolio/performance?tickers= items Record<string, PortfolioPerformanceResponse> 逐檔績效,value 與該檔 ?ticker= 的 response 完全相同
GET /api/portfolio/performance?tickers= as_of_date string \| null 所有 item 中最大的 as_of_date;全部未持有時 null

name 必定存在(可能是 null),不是 optional key。批次 mode 的 items key 順序= 正規化(去空白、轉大寫)去重後的請求順序。

3. 行為改變(Status code / shape)

情境 修前 修後
GET /api/portfolio/holdings 200,row 有 15 個 key 200,row 有 16 個 key(多 name,既有 15 個不變)
未持有任何部位時的 holdings 200 [],不查 stocks 200 [],一樣不查 stocks(名稱只對 net_qty > 0 查)
GET /api/portfolio/performance(不帶參數 / ?ticker= / ?type=) 200 200(完全不變)
GET /api/portfolio/performance?tickers=A,B 參數被忽略,回整體彙總 200,回批次 shape
?tickers= 同時帶 ticker 或 type 參數被忽略 422
?tickers= 去重後為空 參數被忽略 422
?tickers= 去重後超過 100 參數被忽略 422

上限 100 來自 config/core.yaml 的 portfolio.performance.batch_max_tickers,由 investment-agent/backend/tests/test_portfolio_metrics.py::test_performance_batch_max_tickers_invariant 釘住;前端請把「分批大小」做成常數而非散落的 magic number,上限若調整只會在後端 config 動。

前端若用嚴格模式解析(例如 zod 的 .strict()),name 會讓舊 schema 驗證失敗——這是唯一需要動的地方。

4. 情境 response(JSON 由 committed 測試產生)

以下每段 JSON 的 shape 與數值都來自 committed 測試實際跑出來的 TestClient 輸出,不是手寫。唯一的例外是 §4-3 / §4-4 的 baseline_date:測試 fixture 的 baseline 日期是合成的(各期間內的第一個交易日),與本 API 的語意(期間開始前最後一個交易日)相牴觸,因此文件改用與 docs/api-reference.md performance 章節語意一致的日曆日期(2026-05-29 / 2025-12-31)。baseline_value、unrealized_pnl、unrealized_pnl_pct 等數值一律未動,仍是測試輸出原值。

4-1 持倉列表帶名稱(happy path)

產生測試:tests/test_portfolio_holdings.py::test_list_holdings_includes_stock_name

GET /api/portfolio/holdings
[
  {
    "ticker": "2317.TW",
    "name": "鴻海",
    "buy_qty": 20,
    "sell_qty": 0,
    "stock_dividend_qty": 0,
    "net_qty": 20,
    "avg_buy_price": 50.0,
    "buy_amount": 1000.0,
    "sell_amount": 0.0,
    "cost_basis_total": 1000.0,
    "market_price": 60.0,
    "realized_pnl": 0.0,
    "day_trade_realized_pnl": 0.0,
    "total_realized_pnl": 0.0,
    "unrealized_pnl": 200.0,
    "cash_dividend_amount": 0.0
  },
  {
    "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,
    "realized_pnl": 0.0,
    "day_trade_realized_pnl": 0.0,
    "total_realized_pnl": 0.0,
    "unrealized_pnl": 200.0,
    "cash_dividend_amount": 0.0
  }
]

4-2 stocks 查無該檔 → name 為 null

產生測試:tests/test_portfolio_holdings.py::test_list_holdings_name_is_null_when_stocks_has_no_row

name 是 null 不是缺 key,其餘 15 個既有欄位一個不少。前端要有 fallback 顯示(建議退回 ticker)。

[
  {
    "ticker": "2317.TW",
    "name": null,
    "buy_qty": 20,
    "sell_qty": 0,
    "stock_dividend_qty": 0,
    "net_qty": 20,
    "avg_buy_price": 50.0,
    "buy_amount": 1000.0,
    "sell_amount": 0.0,
    "cost_basis_total": 1000.0,
    "market_price": 60.0,
    "realized_pnl": 0.0,
    "day_trade_realized_pnl": 0.0,
    "total_realized_pnl": 0.0,
    "unrealized_pnl": 200.0,
    "cash_dividend_amount": 0.0
  },
  {
    "ticker": "2330.TW",
    "name": null,
    "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,
    "realized_pnl": 0.0,
    "day_trade_realized_pnl": 0.0,
    "total_realized_pnl": 0.0,
    "unrealized_pnl": 200.0,
    "cash_dividend_amount": 0.0
  }
]

4-3 批次逐檔績效(兩檔皆持有)

產生測試:tests/test_portfolio_performance_batch.py::test_batch_items_equal_single_ticker_responses (該測試逐項 assert items["2330.TW"] 等於 ?ticker=2330.TW 的完整 response, items["2317.TW"] 等於 ?ticker=2317.TW 的完整 response)

GET /api/portfolio/performance?tickers=2330.TW,2317.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.285714285714286
        },
        "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": 1000.0,
          "unrealized_pnl": 200.0,
          "unrealized_pnl_pct": 20.0
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 800.0,
          "unrealized_pnl": 400.0,
          "unrealized_pnl_pct": 50.0
        }
      }
    }
  }
}

4-4 批次含未持有的 ticker

產生測試:tests/test_portfolio_performance_batch.py::test_batch_unknown_ticker_returns_empty_periods

未持有不是錯誤:該 key 仍然存在,值是 positions_count: 0、各 period 全 null 的單檔 shape。 前端不需要為缺席的 key 做防禦,只需處理 positions_count === 0。

GET /api/portfolio/performance?tickers=2330.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.285714285714286
        },
        "year": {
          "label": "year",
          "baseline_date": "2025-12-31",
          "baseline_value": 900.0,
          "unrealized_pnl": 300.0,
          "unrealized_pnl_pct": 33.33333333333333
        }
      }
    },
    "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
        }
      }
    }
  }
}

4-5 錯誤情境:tickers 與 ticker 同時提供

產生測試:tests/test_portfolio_performance_batch.py::test_batch_rejects_tickers_with_ticker (同時 assert 這個 422 發生在任何 DB 查詢之前)

GET /api/portfolio/performance?tickers=2330.TW&ticker=2330.TW

HTTP 422,走全域 error envelope(見 docs/api-reference.md §通用 Response 格式 第一類), 不是 FastAPI 參數驗證那種 {"detail": [...]} 陣列。type 同時提供時訊息相同。

{
  "error": {
    "code": "tickers cannot be used together with ticker or type.",
    "message": "tickers cannot be used together with ticker or type.",
    "details": {}
  }
}

4-6 錯誤情境:tickers 去重後為空

產生測試:tests/test_portfolio_performance_batch.py::test_batch_rejects_empty_list

GET /api/portfolio/performance?tickers=,,

HTTP 422。前端應在送出前就過濾掉空清單,不要靠這個 422。

{
  "error": {
    "code": "tickers must contain at least one ticker.",
    "message": "tickers must contain at least one ticker.",
    "details": {}
  }
}

4-7 錯誤情境:超過批次上限

產生測試:tests/test_portfolio_performance_batch.py::test_batch_rejects_more_than_max_tickers (用 MAX_PERFORMANCE_BATCH_TICKERS + 1 檔;剛好等於上限的 case 由 test_batch_accepts_exactly_max_tickers 釘住會回 200)

GET /api/portfolio/performance?tickers=<101 檔>

HTTP 422。去重是在計數之前做的,所以重複的代碼不會吃掉額度。

{
  "error": {
    "code": "tickers accepts at most 100 tickers.",
    "message": "tickers accepts at most 100 tickers.",
    "details": {}
  }
}

5. Consumer 端 type diff

 export interface PortfolioHolding {
   ticker: string;
+  /** {country}.stocks.name;stocks 查無該檔或名稱為 NULL 時為 null。
+   *  顯示時建議 fallback 回 ticker。 */
+  name: string | null;
   buy_qty: number;
   sell_qty: number;
   stock_dividend_qty: number;
   net_qty: number;
   avg_buy_price: number | null;
   buy_amount: number;
   sell_amount: number;
   cost_basis_total: number;
   market_price: number | null;
   realized_pnl: number;
   day_trade_realized_pnl: number;
   total_realized_pnl: number;
   unrealized_pnl: number;
   cash_dividend_amount: number;
 }
+
+/** GET /api/portfolio/performance?tickers=A,B 的 response。
+ *  items 的 value 與 GET /api/portfolio/performance?ticker=A 完全同型。 */
+export interface PortfolioBatchPerformanceResponse {
+  /** 所有 item 中最大的 as_of_date;全部未持有時為 null。 */
+  as_of_date: string | null;
+  /** key = 正規化(去空白、轉大寫)去重後的請求 ticker,順序即請求順序。 */
+  items: Record<string, PortfolioPerformanceResponse>;
+}

6. Consumer 遷移 checklist

  • [ ] 「現有持倉」頁停止為了取得名稱而逐檔呼叫 GET /api/stocks/{ticker},改讀 holdings row 的 name
  • [ ] 名稱為 null 時的顯示 fallback(建議顯示 ticker),不要顯示 null 或空白
  • [ ] 把 N 次 GET /api/portfolio/performance?ticker=X 換成 1 次 GET /api/portfolio/performance?tickers=X,Y,Z
  • [ ] 批次以上限(portfolio.performance.batch_max_tickers,目前 100)分段;持股數 ≤ 100 時就是單一請求
  • [ ] 分段大小抽成常數,不要散落在多處
  • [ ] 送出前過濾空字串/重複代碼(後端會去重,但別把額度浪費在重複項上)
  • [ ] 讀 items[ticker] 時以 positions_count === 0 判斷「未持有」,不要以 key 缺席判斷
  • [ ] tickers 必須送完整外部格式(2330.TW / 8299.TWO / AAPL);bare code(例如 2330)會被視為未持有而回空 period,不會報錯,很容易誤判成「沒持股」
  • [ ] 確認沒有任何地方同時帶 tickers 與 ticker/type(會 422)
  • [ ] 若使用 zod / io-ts 等執行期驗證且為 strict 模式,依 §5 更新 schema

7. 參考

  • 後端規格:docs/api-reference.md §GET /api/portfolio/holdings、§GET /api/portfolio/performance
  • OpenAPI:docs/assets/openapi.json(PortfolioBatchPerformanceResponse)
  • 數值來源測試:investment-agent/backend/tests/test_portfolio_holdings.py、 investment-agent/backend/tests/test_portfolio_performance_batch.py
  • 批次只跑一次 metrics/價格查詢的證據: tests/test_portfolio_performance_batch.py::test_batch_calls_metrics_and_price_fetches_once