持倉名稱 + 批次績效 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