第 7 章:持倉與投資組合查詢
這一章在做什麼? 我們要提供兩支查詢:/holdings 列出原始持股(數量、平均成本),/portfolio 則把持股結合即時報價,算出每檔的市值、未實現損益、損益率,以及整個投資組合的總市值、總成本、總損益。
這章的學習重點? 兩個實務觀念:(1)跨 service 組合資料——投資組合需要同時用到「持股(來自資料庫)」和「現價(來自報價服務)」;(2)回應 DTO 與 DB model 分離——回給前端的形狀(含計算出來的市值、損益)跟資料表的形狀不一樣,我們用獨立的 DTO 來表達,不讓資料表結構直接外洩。
步驟 1:先理解要算什麼
對每一檔持股:
市值 (market value) = 現價 × 持有股數
成本 (cost basis) = 平均成本 × 持有股數
未實現損益 (P/L) = 市值 − 成本
損益率 = 未實現損益 ÷ 成本 × 100%
整個投資組合:
持股總市值 = 所有持股市值加總
總資產 = 現金餘額 + 持股總市值
總損益 = 持股總市值 − 持股總成本
「未實現」是因為還沒賣掉、損益只是帳面上的。現價會隨第 5 章的背景模擬一直跳動,所以每次查投資組合,損益都可能不同。
步驟 2:設計回應 DTO(與 DB model 分開)
我們的資料表 holdings 只有 symbol / quantity / avg_cost(model.Holding)。但投資組合回應還要包含算出來的市值、損益等欄位——這些不在資料表裡。
所以我們定義專屬的 DTO(Data Transfer Object,資料傳輸物件),跟 model.Holding 分開。建立 internal/service/portfolio_service.go,先寫 DTO:
// internal/service/portfolio_service.go
package service
import (
"github.com/jmoiron/sqlx"
"no-logic-trade/internal/model"
"no-logic-trade/internal/repository"
)
// PortfolioItem 單一持股的完整檢視(結合即時報價算出來的,非資料表原貌)
type PortfolioItem struct {
Symbol string
Quantity int64
AvgCost int64 // 每股平均成本(分)
CurrentPrice int64 // 每股現價(分)
MarketValue int64 // 市值(分)
CostBasis int64 // 總成本(分)
UnrealizedPL int64 // 未實現損益(分)
}
// Portfolio 整個投資組合的彙總
type Portfolio struct {
CashBalance int64
PositionsValue int64 // 持股總市值
TotalValue int64 // 總資產 = 現金 + 持股總市值
TotalCost int64 // 持股總成本
TotalUnrealizedPL int64 // 總未實現損益
Items []PortfolioItem
}
🔁 JS 對照 這就是後端常說的「entity vs DTO」或「serializer / view model」。在 Node 你可能用 class-transformer、或手寫一個
toPortfolioDTO(holding, quote)函式,把資料庫實體 + 即時資料組成「要回給前端的形狀」。重點觀念一致:API 回應的形狀,不該等於資料表的形狀。 這樣資料表怎麼改都不會直接影響 API 合約,也不會不小心把內部欄位洩漏出去。
步驟 3:service(跨 service 組合資料)
接在同一個檔案後面:
type PortfolioService struct {
db *sqlx.DB
accounts *repository.AccountRepository
holdings *repository.HoldingRepository
quotes QuoteService // ★ 同時依賴報價服務
}
func NewPortfolioService(
db *sqlx.DB,
accounts *repository.AccountRepository,
holdings *repository.HoldingRepository,
quotes QuoteService,
) *PortfolioService {
return &PortfolioService{db: db, accounts: accounts, holdings: holdings, quotes: quotes}
}
// GetPortfolio 組出完整投資組合(持股 × 即時報價)
func (s *PortfolioService) GetPortfolio(userID int64) (*Portfolio, error) {
account, err := s.accounts.GetByUserID(s.db, userID)
if err != nil {
return nil, err
}
holdings, err := s.holdings.ListByAccount(s.db, account.ID)
if err != nil {
return nil, err
}
p := &Portfolio{CashBalance: account.CashBalance}
for _, h := range holdings {
// 取現價;萬一這檔暫時查不到報價,就退而用平均成本(損益視為 0),不讓查詢整個失敗
currentPrice := h.AvgCost
if quote, err := s.quotes.GetQuote(h.Symbol); err == nil {
currentPrice = quote.Price
}
marketValue := currentPrice * h.Quantity
costBasis := h.AvgCost * h.Quantity
p.Items = append(p.Items, PortfolioItem{
Symbol: h.Symbol,
Quantity: h.Quantity,
AvgCost: h.AvgCost,
CurrentPrice: currentPrice,
MarketValue: marketValue,
CostBasis: costBasis,
UnrealizedPL: marketValue - costBasis,
})
p.PositionsValue += marketValue
p.TotalCost += costBasis
}
p.TotalValue = p.CashBalance + p.PositionsValue
p.TotalUnrealizedPL = p.PositionsValue - p.TotalCost
return p, nil
}
// ListHoldings 只回原始持股(不算市值),給 /holdings 用
func (s *PortfolioService) ListHoldings(userID int64) ([]model.Holding, error) {
account, err := s.accounts.GetByUserID(s.db, userID)
if err != nil {
return nil, err
}
return s.holdings.ListByAccount(s.db, account.ID)
}
重點:
PortfolioService同時持有holdings(repository)與quotes(QuoteService 介面)。這就是「跨 service 組合資料」——它把資料庫的持股,和報價服務的現價,組合成一個有意義的投資組合檢視。- 查詢不需要交易或鎖:這是唯讀操作,拿到的是「查詢當下」的快照,不必像下單那樣上鎖。
- 容錯:若某檔暫時查不到報價,用平均成本代替(損益顯示 0),避免一檔出問題就整個投資組合查不出來。
💡 效能小提醒(N+1):這裡每檔持股都呼叫一次
GetQuote。因為我們的報價是記憶體 map、查詢是 O(1),完全沒問題。但如果報價來自遠端 API,逐檔查就會變成「N+1 次網路請求」很慢——那時應改成「一次批次查多檔」。這是設計報價介面時值得預留的彈性。
步驟 4:handler(把 DTO 轉成 JSON + 算損益率)
建立 internal/handler/portfolio_handler.go:
// internal/handler/portfolio_handler.go
package handler
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"no-logic-trade/internal/service"
)
type PortfolioHandler struct {
portfolio *service.PortfolioService
}
func NewPortfolioHandler(portfolio *service.PortfolioService) *PortfolioHandler {
return &PortfolioHandler{portfolio: portfolio}
}
// plPct 算損益率字串(顯示用的百分比,float 只在這個邊界用一下)
func plPct(costBasis, pl int64) string {
if costBasis == 0 {
return "0.00%"
}
return fmt.Sprintf("%.2f%%", float64(pl)/float64(costBasis)*100)
}
// GET /api/portfolio
func (h *PortfolioHandler) Get(c *gin.Context) {
userID := c.GetInt64("userID")
p, err := h.portfolio.GetPortfolio(userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "查詢投資組合失敗")
return
}
positions := make([]gin.H, 0, len(p.Items))
for _, it := range p.Items {
positions = append(positions, gin.H{
"symbol": it.Symbol,
"quantity": it.Quantity,
"avg_cost": formatCents(it.AvgCost),
"current_price": formatCents(it.CurrentPrice),
"market_value": formatCents(it.MarketValue),
"cost_basis": formatCents(it.CostBasis),
"unrealized_pl": formatCents(it.UnrealizedPL),
"unrealized_pl_cents": it.UnrealizedPL,
"unrealized_pl_pct": plPct(it.CostBasis, it.UnrealizedPL),
})
}
c.JSON(http.StatusOK, gin.H{
"cash_balance": formatCents(p.CashBalance),
"positions_value": formatCents(p.PositionsValue),
"total_value": formatCents(p.TotalValue),
"total_cost": formatCents(p.TotalCost),
"total_unrealized_pl": formatCents(p.TotalUnrealizedPL),
"total_unrealized_pl_pct": plPct(p.TotalCost, p.TotalUnrealizedPL),
"positions": positions,
})
}
// GET /api/holdings
func (h *PortfolioHandler) ListHoldings(c *gin.Context) {
userID := c.GetInt64("userID")
holdings, err := h.portfolio.ListHoldings(userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "查詢持股失敗")
return
}
items := make([]gin.H, 0, len(holdings))
for _, hh := range holdings {
items = append(items, gin.H{
"symbol": hh.Symbol,
"quantity": hh.Quantity,
"avg_cost": formatCents(hh.AvgCost),
})
}
c.JSON(http.StatusOK, gin.H{"holdings": items})
}
重點:
- handler 負責「呈現」:把 service 算好的整數分,用
formatCents轉成好讀字串,並算出顯示用的損益率plPct(百分比是 float,但只在這個輸出邊界用一下,跟第 4 章的策略一致)。 /portfolio和/holdings共用同一個 handler 與 service,只是回的形狀詳簡不同。
步驟 5:組裝路由
更新 setupRouter(兩支都需登入):
// cmd/api/main.go (只列變動處)
// --- service 層(新增 portfolioService)---
portfolioService := service.NewPortfolioService(db, accountRepo, holdingRepo, quoteService) // ★新增
// --- handler 層 ---
portfolioHandler := handler.NewPortfolioHandler(portfolioService) // ★新增
// --- 路由(authed 群組內新增)---
authed.GET("/portfolio", portfolioHandler.Get) // ★新增
authed.GET("/holdings", portfolioHandler.ListHoldings) // ★新增
portfolioService 重用了第 6 章已建立的 accountRepo、holdingRepo、quoteService——又一次印證「組裝中心」把零件拼起來的模式。
✅ 如何執行 / 驗證這一章成功
啟動、登入,並確保手上有一些持股(沿用前面:alice 應持有 AAPL 3 股;可再買一檔 TSLA 讓投資組合更豐富):
go run ./cmd/api
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"secret123"}' | sed 's/.*"token":"//;s/".*//')
# 再買 2 股 TSLA,讓投資組合有兩檔
curl -s -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"symbol":"TSLA","side":"buy","quantity":2}' > /dev/null
驗證 1:查原始持股
curl http://localhost:8080/api/holdings -H "Authorization: Bearer $TOKEN"
預期(平均成本為你買進時的價格):
{"holdings":[
{"avg_cost":"195.50","quantity":3,"symbol":"AAPL"},
{"avg_cost":"250.00","quantity":2,"symbol":"TSLA"}
]}
驗證 2:查完整投資組合
curl http://localhost:8080/api/portfolio -H "Authorization: Bearer $TOKEN"
預期(current_price 為當下報價,與買進價略有差異;損益隨之變動):
{
"cash_balance": "8547.00",
"positions_value": "1086.20",
"total_value": "9633.20",
"total_cost": "1086.50",
"total_unrealized_pl": "-0.30",
"total_unrealized_pl_pct": "-0.03%",
"positions": [
{
"symbol": "AAPL",
"quantity": 3,
"avg_cost": "195.50",
"current_price": "195.42",
"market_value": "586.26",
"cost_basis": "586.50",
"unrealized_pl": "-0.24",
"unrealized_pl_cents": -24,
"unrealized_pl_pct": "-0.04%"
},
{
"symbol": "TSLA",
"quantity": 2,
"avg_cost": "250.00",
"current_price": "250.03",
"market_value": "500.06",
"cost_basis": "500.00",
"unrealized_pl": "0.06",
"unrealized_pl_cents": 6,
"unrealized_pl_pct": "0.01%"
}
]
}
(你的數字會不同,因為價格一直在跳。重點是檢查:market_value = current_price × quantity、unrealized_pl = market_value − cost_basis、total_value = cash_balance + positions_value 都對得起來。)
驗證 3:損益會隨報價變動
等個幾秒(背景模擬器跳價),再查一次投資組合,current_price 與 unrealized_pl 應該變了:
sleep 6
curl -s http://localhost:8080/api/portfolio -H "Authorization: Bearer $TOKEN" \
| grep -o '"total_unrealized_pl":"[^"]*"'
三項都通過,代表持股查詢、跨 service 組合(持股 × 即時報價)、DTO 與 model 分離、以及市值/損益計算都正確運作。🎉
這一章你完成了什麼
- 設計了專屬的回應 DTO(
Portfolio/PortfolioItem),與資料表model.Holding分離,確立「API 形狀 ≠ 資料表形狀」。 - 寫出
PortfolioService,跨來源組合資料(資料庫持股 × 報價服務現價),算出市值、成本、未實現損益與整體彙總。 - 認識唯讀查詢不需上鎖、以及逐檔查報價的 N+1 隱憂與其因應方向。
- 在 handler 把整數分轉成好讀字串與損益率百分比,維持「運算用整數、顯示才轉換」的一致策略。
下一步 → 第 8 章:交易紀錄查詢
我們會做歷史成交與資金異動查詢,支援分頁與篩選(?symbol=&side=&limit=&offset=),學會解析與驗證 query string、組裝動態 SQL 條件,並把第 4 章那個「長相有點醜」的 transactions ledger 用乾淨的 DTO 呈現出來。