第 7 章:持倉與投資組合查詢

第 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_costmodel.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 章已建立的 accountRepoholdingRepoquoteService——又一次印證「組裝中心」把零件拼起來的模式。


✅ 如何執行 / 驗證這一章成功

啟動、登入,並確保手上有一些持股(沿用前面: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 × quantityunrealized_pl = market_value − cost_basistotal_value = cash_balance + positions_value 都對得起來。)

驗證 3:損益會隨報價變動

等個幾秒(背景模擬器跳價),再查一次投資組合,current_priceunrealized_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 呈現出來。