---
# System prepended metadata

title: API對接

---

# Risk Service gRPC API

## Overview

Risk Service 提供馬來賠率風控計算的 gRPC 接口，用於管理投注回合、評估投注風險、計算動態賠率。

**Proto 定義**: `api/proto/risk/v1/risk_service.proto`

---

## Changelog

### v2.6.0 (2025-01)

**New Features:**
- `SubscribeOdds` - 即時賠率推送 Streaming RPC
  - 客戶端訂閱後可接收所有活躍回合的賠率變化
  - 支援推送頻率配置（每回合獨立設定）
  - 支援訂閱過濾（特定 round_ids）
  - 回合結束時自動發送 ROUND_ENDED 事件

**New RPC:**
```protobuf
rpc SubscribeOdds(SubscribeOddsRequest) returns (stream OddsUpdate);
```

**New Request Fields (StartRoundRequest):**
- `odds_push_frequency_ms` (int32) - 賠率推送頻率（毫秒），預設 100ms，範圍 50-5000ms

**New Messages:**
- `SubscribeOddsRequest` - 訂閱請求
  - `round_ids` (repeated string) - 可選：過濾特定回合（空 = 全部）
  - `override_frequency_ms` (int32) - 可選：覆蓋推送頻率
- `OddsUpdate` - 賠率更新事件（包含完整回合狀態，與 GetRoundState 對齊）
  - `round_id`, `meron_odds_bps`, `wala_odds_bps` - 賠率資訊
  - `meron_pool_cents`, `wala_pool_cents` - 池子資訊
  - `update_type` - 更新類型（ODDS_CHANGE / ROUND_STARTED / ROUND_ENDED）
  - `sequence` - 序列號（用於檢測丟失）
  - `round_status`, `winner` - 僅 ROUND_ENDED 時有值
  - `meron_net_income_cents`, `wala_net_income_cents` - 淨收入追蹤
  - `total_bets`, `meron_bets`, `wala_bets` - 投注統計
  - `max_loss_cents`, `effective_max_loss_cents` - 風險敞口
  - `provisional_meron_risk_cents`, `provisional_wala_risk_cents`, `pending_bets` - 待確認風險
  - `started_at` - 回合開始時間
  - `house_profit_cents` - 莊家盈虧（僅 ROUND_ENDED 時有值）

**New Enum:**
- `OddsUpdateType` - 賠率更新類型
  - `ODDS_UPDATE_TYPE_UNSPECIFIED` (0)
  - `ODDS_UPDATE_TYPE_ODDS_CHANGE` (1) - 賠率變更
  - `ODDS_UPDATE_TYPE_ROUND_STARTED` (2) - 新回合開始
  - `ODDS_UPDATE_TYPE_ROUND_ENDED` (3) - 回合結束

**Usage Example:**
```bash
# 訂閱所有回合的賠率更新
grpcurl -plaintext -d '{}' localhost:50051 risk.v1.RiskService/SubscribeOdds

# 訂閱特定回合，覆蓋推送頻率為 200ms
grpcurl -plaintext -d '{"round_ids":["round-001"],"override_frequency_ms":200}' \
  localhost:50051 risk.v1.RiskService/SubscribeOdds

# 開始回合時設定推送頻率
grpcurl -plaintext -d '{"round_id":"round-001","odds_push_frequency_ms":500}' \
  localhost:50051 risk.v1.RiskService/StartRound
```

---

### v2.5.0 (2025-01)

**New Features:**
- `DecayConfig` - 漸進式風險衰減配置
  - 根據風險使用率動態調整賠率，平滑過渡而非突然拒絕
  - 支援多種衰減模式：EXPONENTIAL（指數）、QUADRATIC（二次）、PIECEWISE（分段線性）
  - 可配置衰減起始點、衰減地板、衰減強度

**DecayConfig 參數:**
| 欄位 | 類型 | 預設值 | 說明 |
|------|------|--------|------|
| `enabled` | bool | true | 是否啟用漸進衰減 |
| `mode` | DecayMode | EXPONENTIAL | 衰減模式 |
| `start_ratio_bps` | int32 | 5000 (50%) | 開始衰減的風險使用率 |
| `floor_bps` | int32 | 5000 (50%) | 賠率衰減地板（最低保留比例）|
| `intensity_bps` | int32 | 3000 | 衰減強度（僅 EXPONENTIAL 模式）|
| `zones` | []DecayZone | - | 自定義區間（僅 PIECEWISE 模式）|

**DecayMode 枚舉:**
| 值 | 數值 | 說明 |
|----|------|------|
| `DECAY_MODE_UNSPECIFIED` | 0 | 使用預設 (EXPONENTIAL) |
| `DECAY_MODE_NONE` | 1 | 不衰減，僅使用硬限制 |
| `DECAY_MODE_EXPONENTIAL` | 2 | 指數衰減（最平滑，推薦）|
| `DECAY_MODE_QUADRATIC` | 3 | 二次曲線（前慢後快）|
| `DECAY_MODE_PIECEWISE` | 4 | 自定義分段線性 |

**Usage Example:**
```go
// 使用漸進式風險衰減
resp, _ := client.StartRound(ctx, &pb.StartRoundRequest{
    RoundId: "round-001",
    Config: &pb.RiskConfig{
        MaxRoundLossCents: 10000000,
        DecayConfig: &pb.DecayConfig{
            Enabled:       true,
            Mode:          pb.DECAY_MODE_EXPONENTIAL,
            StartRatioBps: 5000,  // 風險使用率 50% 時開始衰減
            FloorBps:      5000,  // 賠率最低保留 50%
            IntensityBps:  3000,  // 中等衰減速度
        },
    },
})
```

**衰減行為說明:**
- 風險使用率 < 50%：賠率不衰減，維持市場賠率
- 風險使用率 50% ~ 100%：賠率逐步降低至 floor
- 指數衰減比線性衰減更平滑，推薦用於生產環境

### v2.4.0 (2024-12)

**New Features:**
- `auto_confirm` - EvaluateBet 自動確認功能
  - 當 `auto_confirm=true` 且評估結果為 ACCEPTED（賠率沒變），直接返回 CONFIRMED 狀態
  - 減少 RPC 往返：常見情況下從 2 次（EvaluateBet + ConfirmBet）降為 1 次
- `auto_accept_better_odds` - 自動接受更好賠率
  - 需與 `auto_confirm=true` 搭配使用
  - 當實際賠率比玩家預期更好時，也自動確認
- `odds_change` 回應欄位 - 賠率變動方向
  - `ODDS_UNCHANGED` (0): 賠率沒變
  - `ODDS_BETTER` (1): 賠率變好（對玩家有利）
  - `ODDS_WORSE` (2): 賠率變差（對玩家不利）

**New Response Fields (EvaluateBetResponse):**
- `status: CONFIRMED` - 新狀態，表示已自動確認
- `auto_confirmed` (bool) - 是否為自動確認
- `odds_change` (enum) - 賠率變動方向
- `confirmed_at` (int64) - 確認時間戳（自動確認時有值）

**Behavior Summary:**
| auto_confirm | auto_accept_better_odds | 賠率狀態 | 結果 |
|--------------|------------------------|----------|------|
| false | - | - | 原有行為 (ACCEPTED/NEEDS_CONFIRMATION/REJECTED) |
| true | false | unchanged | CONFIRMED (自動確認) |
| true | false | better | NEEDS_CONFIRMATION (需手動確認) |
| true | false | worse | NEEDS_CONFIRMATION (需手動確認) |
| true | true | unchanged | CONFIRMED (自動確認) |
| true | true | better | CONFIRMED (自動確認) |
| true | true | worse | NEEDS_CONFIRMATION (需手動確認) |

**Usage Example:**
```go
// 快速投注：賠率沒變就直接確認
evalResp, _ := client.EvaluateBet(ctx, &pb.EvaluateBetRequest{
    RoundId:              "round-001",
    PlayerId:             "player-001",
    Side:                 pb.Side_SIDE_MERON,
    AmountCents:          1000000,
    BetSlug:              "bet-abc-123",
    ExpectedOddsBps:      8400,
    AutoConfirm:          true,   // 啟用自動確認
    AutoAcceptBetterOdds: true,   // 賠率變好也自動確認
})

if evalResp.Status == pb.EvaluateBetResponse_CONFIRMED {
    // 已自動確認，無需調用 ConfirmBet
    log.Printf("投注已確認: odds=%d, change=%s", evalResp.OriginalOddsBps, evalResp.OddsChange)
} else if evalResp.Status == pb.EvaluateBetResponse_NEEDS_CONFIRMATION {
    // 賠率變差，需要玩家手動確認
    // 顯示 UI 讓玩家選擇接受或取消
}
```

### v2.3.1 (2024-12)

**Bug Fixes:**
- `unlimited_betting` 現在同時影響 EvaluateBet 和 ConfirmBet
  - 修復：ConfirmBet 時若 `unlimited_betting=true`，跳過 Provisional Risk 驗證
  - 之前：ConfirmBet 仍會因 Provisional Risk 累積而拒絕投注
  - 現在：與 EvaluateBet 行為一致，全收所有投注

### v2.3.0 (2024-12)

**Breaking Changes:**
- `auto_cancel_on_loss` 已棄用，請改用 `unlimited_betting` + `force_settle` 組合
- StopRound 取消邏輯變更：ForceSettle=false 時，虧損超限一律建議取消（不再依賴 auto_cancel_on_loss）

**New Features:**
- `unlimited_betting` - 新增獨立控制收單風險檢查的參數
  - `false` (預設): 嚴格風控，超過 MaxRoundLoss 容量時拒絕投注
  - `true`: 跳過風險檢查，按市場賠率全收

**Behavior Changes:**
- 參數職責分離：
  - `unlimited_betting`: 控制 EvaluateBet 和 ConfirmBet 時的風險檢查
  - `force_settle`: 只控制 StopRound 時的取消建議
- 組合效果表：
  | unlimited_betting | force_settle | 收單行為 | 結算行為 |
  |-------------------|--------------|----------|----------|
  | false             | false        | 嚴格風控 | 可取消   |
  | true              | false        | 全收     | 可取消   |
  | true              | true         | 全收     | 一律成局 |
  | false             | true         | 嚴格風控 | 一律成局 |

**Migration Guide:**
- `auto_cancel_on_loss: true` → `unlimited_betting: true` (向後相容，會自動轉換)
- 若需要「全收單 + 一律成局」: 使用 `unlimited_betting: true, force_settle: true`

### v2.2.0 (2024-12)

**Breaking Changes:**
- `ResumeRound` RPC 已移除 - StopRound 現在是永久性的結束下注階段
- `StopRound` - 現在會執行風險評估並返回取消建議

**New Features:**
- `StopRoundResponse` 新增風險評估欄位：
  - `should_cancel` - 是否建議取消回合
  - `cancel_reason` - 建議取消原因
  - `is_one_sided` - 是否單邊投注
  - `meron_wins_exceeds_max_loss` / `wala_wins_exceeds_max_loss` - 各方勝利時是否會超過最大虧損
  - `if_meron_wins_profit_cents` / `if_wala_wins_profit_cents` - 各方勝利時莊家盈虧
  - `max_allowed_cents` - 配置的最大允許虧損
- 風險檢查從 EndRound 移至 StopRound，呼叫方根據建議決定後續動作

**Behavior Changes:**
- `StopRound` - 永久結束下注階段（無法恢復），清除待確認投注，執行風險評估
- `EndRound` - 簡化為純結算，不再執行風險檢查（auto_cancel_on_loss 和 accept_one_side_bet 現在在 StopRound 評估）
- 呼叫方應先調用 `StopRound`，根據 `should_cancel` 決定調用 `EndRound` 或 `CancelRound`

### v2.1.0 (2024-12)

**New Features:**
- `StopRound` RPC - 暫停回合投注，暫停後不接受新投注和確認
- ~~`ResumeRound` RPC~~ - 已在 v2.2.0 移除
- `ROUND_STATUS_FILTER_STOPPED` - 新增回合狀態過濾選項
- `ROUND_STOPPED` 錯誤碼 - 回合已暫停時的錯誤回應
- `ConfirmBetResponse.failure_reason` - 新增失敗原因欄位

**Behavior Changes:**
- `EvaluateBet` - 暫停的回合返回 `REJECTED` (rejection_reason: `round_stopped`)
- `ConfirmBet` - 暫停的回合返回 `FAILED` (待確認投注會被清除)
- 暫停的回合仍可調用 `EndRound` / `CancelRound` 結束

---

## Flow Diagrams

### 完整回合生命週期

```mermaid
sequenceDiagram
    participant R as Rails
    participant S as Risk Service
    participant St as State

    %% 開始回合
    rect rgb(240, 248, 255)
        Note over R,St: 1. 開始回合
        R->>S: StartRound(round_id, config)
        S->>St: Create RoundState
        Note over St: ACTIVE
        S-->>R: initial_meron_odds_bps: 8400<br/>initial_wala_odds_bps: -10000
    end

    %% 投注階段
    rect rgb(255, 250, 240)
        Note over R,St: 2. 投注階段 (可重複多次)
        loop 每筆投注
            R->>S: EvaluateBet(player_id, side, amount, bet_slug)
            S-->>R: status: ACCEPTED
            R->>S: ConfirmBet(bet_slug, ACCEPT)
            S->>St: 記錄投注
            S-->>R: status: SUCCESS
        end
    end

    %% 結束下注階段 (v2.2.0 新流程)
    rect rgb(255, 243, 224)
        Note over R,St: 3. 結束下注階段 (永久性)
        R->>S: StopRound(round_id)
        S->>St: 清除待確認投注
        S->>S: 風險評估
        Note over St: STOPPED
        S-->>R: should_cancel, cancel_reason<br/>is_one_sided, if_meron/wala_wins_profit
    end

    %% 結算階段
    rect rgb(240, 255, 240)
        Note over R,St: 4. 結算階段 (根據 StopRound 建議)
        alt should_cancel = false
            R->>S: EndRound(round_id, winner)
            S->>St: Calculate Settlement
            Note over St: ENDED
            S-->>R: house_profit_cents: 12300
        else should_cancel = true
            R->>S: CancelRound(round_id, reason)
            S->>St: Refund All Bets
            Note over St: CANCELLED
            S-->>R: refunded_bets: 15
        end
    end
```

### 風險評估與取消建議 (v2.2.0 新流程)

StopRound 現在會執行風險評估，返回取消建議。呼叫方根據建議決定調用 EndRound 或 CancelRound。

```mermaid
sequenceDiagram
    participant R as Rails
    participant S as Risk Service
    participant St as State

    %% 開始回合
    rect rgb(240, 248, 255)
        Note over R,St: 1. 開始回合
        R->>S: StartRound(config)
        Note right of R: unlimited_betting: true<br/>force_settle: false
        S->>St: 建立 RoundState
        S-->>R: initial_odds_bps
        Note over St: ACTIVE
    end

    %% 投注階段
    rect rgb(255, 250, 240)
        Note over R,St: 2. 投注階段
        loop 大量單邊投注
            R->>S: EvaluateBet(MERON, 大額)
            S-->>R: ACCEPTED / NEEDS_CONFIRMATION
            R->>S: ConfirmBet
            S->>St: 累積風險敞口
            S-->>R: SUCCESS
        end
        Note over St: 風險敞口接近 MaxRoundLoss
    end

    %% 結束下注 (風險評估)
    rect rgb(255, 243, 224)
        Note over R,St: 3. 結束下注 + 風險評估
        R->>S: StopRound(round_id)
        S->>St: 清除待確認投注
        S->>S: 風險評估
        Note right of S: 檢查 is_one_sided<br/>檢查 would_exceed_max_loss

        alt force_settle=true
            S-->>R: should_cancel: false
        else 單邊投注 & accept_one_side_bet=false
            S-->>R: should_cancel: true<br/>cancel_reason: "one_sided_betting"
        else 虧損超限 (v2.3.0: 不需 auto_cancel_on_loss)
            S-->>R: should_cancel: true<br/>cancel_reason: "max_round_loss_exceeded"<br/>if_meron_wins_profit_cents: -15000000
        else 正常
            S-->>R: should_cancel: false
        end
        Note over St: STOPPED
    end

    %% 結算階段 (根據建議)
    rect rgb(240, 255, 240)
        Note over R,St: 4. 結算階段 (根據建議決定)
        alt should_cancel = true
            R->>S: CancelRound(round_id, cancel_reason)
            S->>St: 退還所有投注
            Note over St: CANCELLED
            S-->>R: refunded_bets: 50
        else should_cancel = false
            R->>S: EndRound(round_id, winner: MERON)
            S->>St: 純結算 (無風險檢查)
            Note over St: ENDED
            S-->>R: house_profit_cents: 12300
        end
    end
```

**StopRound 風險評估欄位：**

| 欄位 | 說明 |
|------|------|
| `should_cancel` | 建議是否取消回合 |
| `cancel_reason` | 建議取消原因 (`one_sided_betting` / `max_round_loss_exceeded`) |
| `is_one_sided` | 是否為單邊投注 (MeronPool=0 或 WalaPool=0) |
| `meron_wins_exceeds_max_loss` | 若 Meron 贏，虧損是否超過 MaxRoundLoss |
| `wala_wins_exceeds_max_loss` | 若 Wala 贏，虧損是否超過 MaxRoundLoss |
| `if_meron_wins_profit_cents` | 若 Meron 贏，莊家盈虧 (分) |
| `if_wala_wins_profit_cents` | 若 Wala 贏，莊家盈虧 (分) |
| `max_allowed_cents` | 配置的最大允許虧損 (分) |

**行為對比 (v2.1.0 vs v2.2.0)：**

| 版本 | 風險檢查位置 | 決策者 |
|------|-------------|--------|
| v2.1.0 | EndRound 內部自動判斷 | Risk Service |
| v2.2.0 | StopRound 返回建議 | 呼叫方 (Rails) |

**優點：**
- 呼叫方有完整決策權，可根據業務需求覆寫建議
- 風險狀態在結束下注時即確定，避免結算時的意外
- 清晰的職責分離：StopRound 評估，呼叫方決策，EndRound/CancelRound 執行

### 兩階段投注流程

```mermaid
sequenceDiagram
    participant R as Rails
    participant S as Risk Service
    participant P as PendingBet

    %% 評估階段
    rect rgb(240, 248, 255)
        Note over R,P: 1. EvaluateBet 評估階段
        R->>S: EvaluateBet
        Note right of R: round_id, player_id<br/>side: MERON<br/>amount_cents: 1000000<br/>bet_slug: "BET-ABC-123"

        S->>S: Risk Assessment
        Note right of S: Check player limit<br/>Calculate max_loss<br/>Determine odds
    end

    %% 回應分支
    alt Scenario A: ACCEPTED (原賠率可用)
        rect rgb(240, 255, 240)
            S->>P: Store PendingBet
            Note over P: expires_at: now + 5min
            S-->>R: status: ACCEPTED<br/>original_odds_bps: 7800<br/>bet_slug: "BET-ABC-123"

            R->>S: ConfirmBet(ACCEPT, odds: 7800)
            S->>P: Commit to Round
            Note over P: Confirmed
            S-->>R: status: SUCCESS<br/>final_odds_bps: 7800
        end

    else Scenario B: NEEDS_CONFIRMATION (賠率被調整)
        rect rgb(255, 255, 224)
            S->>P: Store PendingBet with adjusted_odds
            S-->>R: status: NEEDS_CONFIRMATION<br/>original_odds_bps: 7800<br/>adjusted_odds_bps: 5200

            Note over R: 顯示給玩家:<br/>賠率從 0.78 調整為 0.52<br/>是否接受？

            alt 玩家接受
                R->>S: ConfirmBet(ACCEPT, odds: 5200)
                S->>P: Commit to Round
                Note over P: Confirmed
                S-->>R: status: SUCCESS<br/>final_odds_bps: 5200
            else 玩家取消
                R->>S: ConfirmBet(CANCEL)
                S->>P: Remove PendingBet
                Note over P: Cancelled
                S-->>R: status: CANCELLED
            end
        end

    else Scenario C: REJECTED (風控拒絕)
        rect rgb(255, 240, 240)
            S-->>R: status: REJECTED<br/>rejection_reason: "..."
            Note over P: No State
        end
    end
```

### 冪等性處理 (Idempotency)

`bet_slug` 由 Rails 生成，作為投注的唯一標識符，確保重試安全。

```mermaid
sequenceDiagram
    participant R as Rails
    participant S as Risk Service

    %% 情況 1: 網路超時後重試 EvaluateBet
    rect rgb(255, 250, 240)
        Note over R,S: 情況 1: 網路超時後重試 EvaluateBet
        R->>S: EvaluateBet(bet_slug: "X")
        Note right of S: 已處理並確認
        S--xR: (timeout)

        R->>S: EvaluateBet(bet_slug: "X")
        Note right of R: 重試相同 bet_slug
        S-->>R: status: ALREADY_CONFIRMED
        Note right of S: 或 ALREADY_CANCELLED
    end

    %% 情況 2: 使用 GetBetStatus 查詢
    rect rgb(240, 248, 255)
        Note over R,S: 情況 2: 使用 GetBetStatus 查詢
        R->>S: ConfirmBet(bet_slug: "X")
        Note right of S: 可能已處理
        S--xR: (timeout)

        R->>S: GetBetStatus(bet_slug: "X")
        Note right of R: 查詢實際狀態
        S-->>R: status: CONFIRMED<br/>final_odds_bps: 7800
        Note right of S: 確認已成功
    end
```

### 錯誤處理流程

```mermaid
sequenceDiagram
    participant R as Rails
    participant S as Risk Service

    R->>S: EvaluateBet(...)

    alt INVALID_ARGUMENT (不要重試)
        rect rgb(255, 240, 240)
            S-->>R: [INVALID_ROUND_ID] round_id 格式無效
            S-->>R: [INVALID_PLAYER_ID] player_id 為空
            S-->>R: [INVALID_SIDE] side 未指定
            S-->>R: [INVALID_BET_SLUG] bet_slug 為空
            S-->>R: [INVALID_AMOUNT] amount <= 0
        end

    else NOT_FOUND (資源不存在)
        rect rgb(255, 250, 240)
            S-->>R: [ROUND_NOT_FOUND] 回合尚未開始或已結束
        end

    else ALREADY_EXISTS (重複建立)
        rect rgb(255, 255, 224)
            S-->>R: [ROUND_ALREADY_EXISTS] 回合已存在
        end

    else PERMISSION_DENIED (權限錯誤)
        rect rgb(255, 230, 230)
            S-->>R: player_id mismatch (ConfirmBet 時)
        end

    else FAILED_PRECONDITION (狀態不符)
        rect rgb(240, 240, 255)
            S-->>R: bet already processed 投注已處理
        end

    else INTERNAL (可重試)
        rect rgb(240, 248, 255)
            S-->>R: server error 伺服器內部錯誤
            Note right of R: 建議使用指數退避重試
        end
    end
```

**錯誤處理建議：**

| gRPC Code | 是否重試 | 說明 |
|-----------|---------|------|
| `INVALID_ARGUMENT` | 否 | 修正參數後重試 |
| `NOT_FOUND` | 否 | 確認回合已開始 |
| `ALREADY_EXISTS` | 否 | 回合已存在，直接使用 |
| `PERMISSION_DENIED` | 否 | 檢查 player_id |
| `FAILED_PRECONDITION` | 否 | 使用 GetBetStatus 查詢 |
| `INTERNAL` | 是 | 指數退避重試 |

---

## Precision Units

### Money (金額)
- 單位: 分 (cents)
- 換算: 1 元 = 100 分
- 欄位後綴: `_cents`
- 範例: `1000000` 分 = 10,000 元

### Odds (賠率)
- 單位: 基點 (basis points)
- 換算: 1.0 = 10000 基點
- 欄位後綴: `_bps`
- 馬來賠率範圍:
  - 正數: 3000 ~ 9900 (0.30 ~ 0.99)
  - 負數: -10000 ~ -3000 (-1.0 ~ -0.30)
- 範例: `7800` bps = 0.78, `-10000` bps = -1.0

### Rate (比率)
- 單位: 基點 (basis points)
- 換算: 100% = 10000 基點
- 欄位後綴: `_bps`
- 範例: `450` bps = 4.5%

---

## Enums

### Side
投注方/贏家。

| 值 | 數值 | 說明 |
|----|------|------|
| `SIDE_UNSPECIFIED` | 0 | 無效值，服務端拒絕 |
| `SIDE_MERON` | 1 | 紅方 |
| `SIDE_WALA` | 2 | 藍方 |

### PlayerChoice
玩家對調整賠率的選擇。

| 值 | 數值 | 說明 |
|----|------|------|
| `PLAYER_CHOICE_UNSPECIFIED` | 0 | 無效值，服務端拒絕 |
| `PLAYER_CHOICE_ACCEPT` | 1 | 接受調整後賠率 |
| `PLAYER_CHOICE_CANCEL` | 2 | 取消投注 |

---

## Service: RiskService

### Round Lifecycle (回合生命週期)

#### StartRound
開始新回合。

```protobuf
rpc StartRound(StartRoundRequest) returns (StartRoundResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合唯一標識 |
| `config` | RiskConfig | No | 風控配置，使用預設值若未提供 |
| `odds_push_frequency_ms` | int32 | No | (v2.6.0) 賠率推送頻率（毫秒），預設 100ms，範圍 50-5000ms |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `initial_meron_odds_bps` | int32 | 開盤 Meron 賠率 (基點) |
| `initial_wala_odds_bps` | int32 | 開盤 Wala 賠率 (基點) |

**Errors**:
- `ALREADY_EXISTS`: 回合已存在

---

#### GetRoundState
查詢回合狀態。

```protobuf
rpc GetRoundState(GetRoundStateRequest) returns (RoundStateResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `round_id` | string | 回合 ID |
| `meron_pool_cents` | int64 | Meron 池 (分) |
| `wala_pool_cents` | int64 | Wala 池 (分) |
| `meron_net_income_cents` | int64 | Meron 淨收入 (分) |
| `wala_net_income_cents` | int64 | Wala 淨收入 (分) |
| `total_bets` | int32 | 總投注數 |
| `max_loss_cents` | int64 | 最大可能損失 (分) - 僅含已確認投注 |
| `current_meron_odds_bps` | int32 | 當前 Meron 賠率 (基點) |
| `current_wala_odds_bps` | int32 | 當前 Wala 賠率 (基點) |
| `started_at` | int64 | 開始時間 (Unix timestamp) |
| **Provisional Risk (v2.2.0+)** | | |
| `provisional_meron_risk_cents` | int64 | Meron 方向的暫存風險 (分) |
| `provisional_wala_risk_cents` | int64 | Wala 方向的暫存風險 (分) |
| `pending_bets` | int32 | 待確認投注數量 |
| `effective_max_loss_cents` | int64 | 有效最大損失 (含 Provisional Risk, 分) |

**Errors**:
- `NOT_FOUND`: 回合不存在

---

#### EndRound
結束回合並結算。

**重要 (v2.2.0 變更)**：EndRound 現在是純結算操作，不再執行風險檢查。風險評估已移至 StopRound。
建議流程：先調用 StopRound 獲取風險評估，根據 `should_cancel` 決定調用 EndRound 或 CancelRound。

```protobuf
rpc EndRound(EndRoundRequest) returns (EndRoundResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `winner` | Side | Yes | 贏家 (SIDE_MERON 或 SIDE_WALA) |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `house_profit_cents` | int64 | 莊家盈利 (分) |
| `total_bets` | int32 | 總投注數 |
| `auto_cancelled` | bool | 永遠為 false (v2.2.0 後 EndRound 不會自動取消) |
| `final_status` | RoundFinalStatus | 永遠為 SETTLED (v2.2.0 後 EndRound 只做結算) |
| `cancel_reason` | string | 永遠為空 (取消邏輯已移至 StopRound) |
| `winner` | Side | 勝方 |
| `meron_pool_cents` | int64 | Meron 總投注 (分) |
| `wala_pool_cents` | int64 | Wala 總投注 (分) |
| `meron_bets` | int32 | Meron 投注數 |
| `wala_bets` | int32 | Wala 投注數 |
| `final_meron_odds_bps` | int32 | 最終 Meron 賠率 (基點) |
| `final_wala_odds_bps` | int32 | 最終 Wala 賠率 (基點) |
| `started_at` | int64 | 開始時間 (Unix timestamp) |
| `ended_at` | int64 | 結束時間 (Unix timestamp) |
| `max_exposure_cents` | int64 | 結算前最大風險敞口 (分) |
| `max_allowed_cents` | int64 | 配置的最大允許損失 (分) |
| `remaining_reserve_cents` | int64 | 結算前剩餘額度 (分) |
| `meron_net_income_cents` | int64 | Meron 淨收入 (分) |
| `wala_net_income_cents` | int64 | Wala 淨收入 (分) |
| `if_other_wins_profit_cents` | int64 | 若另一方贏，莊家盈虧 (分) |
| `min_bet_amount_cents` | int64 | 配置的最小投注額 (分) |
| `max_bet_amount_cents` | int64 | 配置的最大投注額 (分) |
| `rake_rate_bps` | int32 | 配置的抽水率 (基點) |
| `odds_floor_bps` | int32 | 配置的賠率地板 (基點) |
| `odds_ceiling_bps` | int32 | 配置的賠率天花板 (基點) |

**RoundFinalStatus 枚舉**:
| 值 | 數值 | 說明 |
|----|------|------|
| `ROUND_FINAL_STATUS_UNSPECIFIED` | 0 | 未指定 |
| `ROUND_FINAL_STATUS_SETTLED` | 1 | 正常結算 |
| `ROUND_FINAL_STATUS_CANCELLED` | 2 | 被取消 (僅由 CancelRound 返回) |

**Errors**:
- `NOT_FOUND`: 回合不存在
- `INVALID_ARGUMENT`: winner 無效
- `FAILED_PRECONDITION`: 回合未停止 (應先調用 StopRound)

---

#### CancelRound
取消回合，退還所有投注。

```protobuf
rpc CancelRound(CancelRoundRequest) returns (CancelRoundResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `reason` | string | No | 取消原因 |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `refunded_bets` | int32 | 退款投注數 |

**Errors**:
- `NOT_FOUND`: 回合不存在

---

#### StopRound
永久結束回合下注階段。停止後執行風險評估，返回取消建議供呼叫方決策。

**重要 (v2.2.0 變更)**：StopRound 現在是永久性操作，無法恢復投注。ResumeRound 已移除。

```protobuf
rpc StopRound(StopRoundRequest) returns (StopRoundResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `reason` | string | No | 停止原因 (用於審計) |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| **時間戳** | | |
| `stopped_at` | int64 | 停止時間 (Unix timestamp) |
| `started_at` | int64 | 回合開始時間 (Unix timestamp) |
| **Pending Bets 處理結果** | | |
| `pending_bets_expired` | int32 | 過期/清理的待確認投注數 |
| `provisional_risk_released_cents` | int64 | 釋放的 Provisional Risk (分) |
| **投注統計** | | |
| `confirmed_bets` | int32 | 已確認投注總數 |
| `meron_bets` | int32 | Meron 投注數 |
| `wala_bets` | int32 | Wala 投注數 |
| **池資訊 (分)** | | |
| `meron_pool_cents` | int64 | Meron 總投注 |
| `wala_pool_cents` | int64 | Wala 總投注 |
| **淨收入追蹤 (分)** | | |
| `meron_net_income_cents` | int64 | Meron 淨收入 |
| `wala_net_income_cents` | int64 | Wala 淨收入 |
| **當前賠率 (基點)** | | |
| `current_meron_odds_bps` | int32 | 當前 Meron 賠率 |
| `current_wala_odds_bps` | int32 | 當前 Wala 賠率 |
| **假設性結算分析** | | |
| `if_meron_wins_profit_cents` | int64 | 若 Meron 贏，莊家盈虧 (分，正=盈利，負=虧損) |
| `if_wala_wins_profit_cents` | int64 | 若 Wala 贏，莊家盈虧 (分) |
| **風險評估結果** | | |
| `should_cancel` | bool | **建議是否取消回合** |
| `cancel_reason` | string | 建議取消原因 (`one_sided_betting` / `max_round_loss_exceeded`) |
| **詳細風險分析** | | |
| `is_one_sided` | bool | 是否單邊投注 (Meron 或 Wala 池為 0) |
| `meron_wins_exceeds_max_loss` | bool | 若 Meron 贏，虧損是否超過 MaxRoundLoss |
| `wala_wins_exceeds_max_loss` | bool | 若 Wala 贏，虧損是否超過 MaxRoundLoss |
| **風險敞口資訊** | | |
| `max_exposure_cents` | int64 | 最大風險敞口 (分) = 兩方結算中虧損較大者 |
| `max_allowed_cents` | int64 | 配置的 MaxRoundLoss (分) |
| **使用的風控配置** | | |
| `min_bet_amount_cents` | int64 | 最小投注金額 (分) |
| `max_bet_amount_cents` | int64 | 最大投注金額 (分) |
| `rake_rate_bps` | int32 | 抽水率 (基點) |
| `odds_floor_bps` | int32 | 賠率下限 (基點) |
| `odds_ceiling_bps` | int32 | 賠率上限 (基點) |

**風險評估邏輯** (v2.3.0 更新):
1. 若 `force_settle = true`：
   - `should_cancel = false`，跳過所有檢查
2. 若 `accept_one_side_bet = false` 且 MeronPool=0 或 WalaPool=0：
   - `should_cancel = true`, `cancel_reason = "one_sided_betting"`
3. 若最大虧損超過 MaxRoundLoss：
   - `should_cancel = true`, `cancel_reason = "max_round_loss_exceeded"`
   - 注意：v2.3.0 後不再依賴 `auto_cancel_on_loss`，ForceSettle=false 時一律檢查

**行為說明**:
- 停止後 **EvaluateBet** 返回 `REJECTED` (reason: `round_stopped`)
- 停止後 **ConfirmBet** 返回 `FAILED` (待確認投注已被清除)
- 待確認投注 (PendingBets) 會被立即清除，釋放 ProvisionalRisk
- **GetRoundState** / **GetCurrentOdds** / **GetRiskExposure** 正常運作
- 呼叫方應根據 `should_cancel` 決定調用 **EndRound** 或 **CancelRound**
- 冪等性：對已停止的回合再次調用 StopRound 返回相同結果

**典型使用流程**:
```
StopRound(round_id)
  → 檢查 should_cancel
  → 若 true: CancelRound(round_id, cancel_reason)
  → 若 false: EndRound(round_id, winner)
```

**Errors**:
- `NOT_FOUND`: 回合不存在
- `FAILED_PRECONDITION`: 回合已結算或已取消

---

### Betting Flow (投注流程)

投注採用兩階段提交：
1. **EvaluateBet**: 評估投注，返回狀態和賠率
2. **ConfirmBet**: 玩家確認或取消

**重要：**
- `bet_slug` (由 Rails 提供) 作為冪等性 key
- **評估有效期 30 秒** (`TokenTTL`)：EvaluateBet 後需在 30 秒內完成 ConfirmBet，超時返回 `BET_EXPIRED` 錯誤
- 客戶端應在收到 EvaluateBet 回應後立即顯示確認 UI，並在 30 秒內完成 ConfirmBet

#### EvaluateBet
評估投注風險。

```protobuf
rpc EvaluateBet(EvaluateBetRequest) returns (EvaluateBetResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `player_id` | string | Yes | 玩家 ID |
| `side` | Side | Yes | 投注方 |
| `amount_cents` | int64 | Yes | 投注金額 (分) |
| `bet_slug` | string | Yes | 投注單號 (冪等性 key) |
| `expected_odds_bps` | int32 | No | 玩家預期賠率 (基點)，Rails 顯示給玩家的賠率 (0=不檢查) |
| `auto_confirm` | bool | No | **[v2.4.0]** 啟用自動確認，若評估結果為 ACCEPTED 則直接確認 |
| `auto_accept_better_odds` | bool | No | **[v2.4.0]** 賠率變好時也自動確認 (需 auto_confirm=true) |

**賠率變動檢測**:
當 `expected_odds_bps` 有值時，系統會比較實際提供的賠率與玩家預期：
- 若賠率不同（無論變好或變差）→ 返回 `NEEDS_CONFIRMATION`
- 若賠率相同 → 返回 `ACCEPTED`

這確保玩家看到的賠率與實際成交的賠率一致，避免因投注延遲造成的賠率差異。

**自動確認邏輯 (v2.4.0)**:
當 `auto_confirm=true` 時，系統會在符合條件時自動完成確認：
- 賠率沒變 (`odds_change=UNCHANGED`) → 自動確認，返回 `CONFIRMED`
- 賠率變好 (`odds_change=BETTER`) + `auto_accept_better_odds=true` → 自動確認
- 賠率變差 (`odds_change=WORSE`) → 不自動確認，返回 `NEEDS_CONFIRMATION`

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `status` | Status | 評估狀態 |
| `original_odds_bps` | int32 | 原始賠率 (基點) |
| `adjusted_odds_bps` | int32 | 調整後賠率 (基點)，僅 NEEDS_CONFIRMATION 時有值 |
| `bet_slug` | string | 投注單號 |
| `expires_at` | int64 | 過期時間 (Unix timestamp) |
| `rejection_reason` | string | 拒絕原因，僅 REJECTED 時有值 |
| `auto_confirmed` | bool | **[v2.4.0]** 是否已自動確認 |
| `odds_change` | OddsChange | **[v2.4.0]** 賠率變動方向 |
| `confirmed_at` | int64 | **[v2.4.0]** 確認時間 (Unix timestamp)，僅自動確認時有值 |

**Status 枚舉**:
| 值 | 數值 | 說明 |
|----|------|------|
| `ACCEPTED` | 0 | 接受，可直接確認 |
| `NEEDS_CONFIRMATION` | 1 | 賠率被調整，需玩家確認 |
| `REJECTED` | 2 | 拒絕 |
| `ALREADY_CONFIRMED` | 3 | 已確認 (冪等回應) |
| `ALREADY_CANCELLED` | 4 | 已取消 (冪等回應) |
| `CONFIRMED` | 5 | **[v2.4.0]** 已自動確認 |

**OddsChange 枚舉 (v2.4.0)**:
| 值 | 數值 | 說明 |
|----|------|------|
| `ODDS_UNCHANGED` | 0 | 賠率沒變 |
| `ODDS_BETTER` | 1 | 賠率變好（對玩家有利，實際賠率 > 預期賠率） |
| `ODDS_WORSE` | 2 | 賠率變差（對玩家不利，實際賠率 < 預期賠率） |

**Errors**:
- `INVALID_ARGUMENT`: 參數無效 (round_id, player_id, side, bet_slug, amount)
- `NOT_FOUND`: 回合不存在

---

#### ConfirmBet
確認或取消投注。

```protobuf
rpc ConfirmBet(ConfirmBetRequest) returns (ConfirmBetResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `bet_slug` | string | Yes | 投注單號 |
| `player_id` | string | Yes | 玩家 ID (驗證歸屬) |
| `player_choice` | PlayerChoice | Yes | 玩家選擇 |
| `accepted_odds_bps` | int32 | No | 接受的賠率 (基點) |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `status` | Status | 確認狀態 |
| `bet_slug` | string | 投注單號 |
| `final_odds_bps` | int32 | 最終賠率 (基點) |
| `failure_reason` | string | 失敗原因，僅 FAILED 時有值 (如: `round_stopped`) |

**Status 枚舉**:
| 值 | 數值 | 說明 |
|----|------|------|
| `SUCCESS` | 0 | 確認成功 |
| `FAILED` | 1 | 確認失敗 |
| `EXPIRED` | 2 | 已過期 |
| `CANCELLED` | 3 | 已取消 |
| `ALREADY_CONFIRMED` | 4 | 已確認 (冪等回應) |

**Errors**:
- `INVALID_ARGUMENT`: 參數無效
- `NOT_FOUND`: 回合不存在
- `PERMISSION_DENIED`: player_id 不匹配

---

#### GetBetStatus
查詢投注狀態 (用於網路超時後確認結果)。

```protobuf
rpc GetBetStatus(GetBetStatusRequest) returns (GetBetStatusResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |
| `bet_slug` | string | Yes | 投注單號 |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `status` | Status | 投注狀態 |
| `bet_slug` | string | 投注單號 |
| `final_odds_bps` | int32 | 最終賠率 (基點) |
| `evaluated_at` | int64 | 評估時間 |
| `confirmed_at` | int64 | 確認時間 (0 表示未確認) |
| `expires_at` | int64 | 過期時間 |

**Status 枚舉**:
| 值 | 數值 | 說明 |
|----|------|------|
| `NOT_FOUND` | 0 | 找不到 |
| `PENDING` | 1 | 等待確認 |
| `CONFIRMED` | 2 | 已確認 |
| `CANCELLED` | 3 | 已取消 |
| `EXPIRED` | 4 | 已過期 |

**查詢邏輯 (v2.4.0+)**:
1. 先從記憶體查詢活躍回合
2. 若回合已結束 (不在記憶體)，自動 fallback 到 MySQL 查詢歷史記錄
3. 回合結束後仍可查詢到已確認的投注狀態

---

### Real-time Streaming (即時推送) (v2.6.0)

#### SubscribeOdds
訂閱即時賠率更新。連接後會收到所有活躍回合的賠率變化。

```protobuf
rpc SubscribeOdds(SubscribeOddsRequest) returns (stream OddsUpdate);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_ids` | repeated string | No | 過濾特定回合（空 = 接收全部）|
| `override_frequency_ms` | int32 | No | 覆蓋推送頻率（毫秒），範圍 50-5000ms |

**Streamed Response (OddsUpdate)**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `round_id` | string | 回合 ID |
| `meron_odds_bps` | int32 | Meron 賠率 (基點) |
| `wala_odds_bps` | int32 | Wala 賠率 (基點) |
| `meron_pool_cents` | int64 | Meron 池 (分) |
| `wala_pool_cents` | int64 | Wala 池 (分) |
| `timestamp_ms` | int64 | 時間戳 (毫秒) |
| `update_type` | OddsUpdateType | 更新類型 |
| `sequence` | uint64 | 序列號（用於檢測丟失）|
| `round_status` | string | 回合狀態（僅 ROUND_ENDED 時有值：settled/cancelled）|
| `winner` | string | 勝方（僅結算時有值：meron/wala）|
| `meron_net_income_cents` | int64 | Meron 淨收入 (分) - 若 Meron 贏，莊家的淨收入 |
| `wala_net_income_cents` | int64 | Wala 淨收入 (分) - 若 Wala 贏，莊家的淨收入 |
| `total_bets` | int32 | 總投注數 |
| `meron_bets` | int32 | Meron 投注數 |
| `wala_bets` | int32 | Wala 投注數 |
| `max_loss_cents` | int64 | 當前最大可能虧損 (分) |
| `effective_max_loss_cents` | int64 | 有效最大虧損 (分) - 考慮預備金限制 |
| `provisional_meron_risk_cents` | int64 | Meron 暫存風險 (分) - 待確認投注的風險預留 |
| `provisional_wala_risk_cents` | int64 | Wala 暫存風險 (分) - 待確認投注的風險預留 |
| `pending_bets` | int32 | 待確認投注數 |
| `started_at` | int64 | 回合開始時間 (Unix timestamp) |
| `house_profit_cents` | int64 | 莊家盈虧 (分) - 僅 ROUND_ENDED 時有值 |

**OddsUpdateType**:
| 值 | 數值 | 說明 |
|----|------|------|
| `ODDS_UPDATE_TYPE_UNSPECIFIED` | 0 | 未指定 |
| `ODDS_UPDATE_TYPE_ODDS_CHANGE` | 1 | 賠率變更（定期推送）|
| `ODDS_UPDATE_TYPE_ROUND_STARTED` | 2 | 新回合開始 |
| `ODDS_UPDATE_TYPE_ROUND_ENDED` | 3 | 回合結束 |

**行為說明**:
- 連接後立即收到所有活躍回合的當前狀態（`ODDS_CHANGE` 類型）
- 新回合開始時收到 `ROUND_STARTED` 事件
- 定期收到 `ODDS_CHANGE` 事件（頻率由 StartRound 的 `odds_push_frequency_ms` 決定）
- 回合結束時收到 `ROUND_ENDED` 事件（含最終狀態和勝方）
- 若訂閱特定 `round_ids`，所有這些回合結束後，stream 會自動關閉
- 訂閱者緩衝區滿時，舊的更新會被丟棄（非阻塞設計）

**推送頻率限制**:
| 限制 | 值 |
|------|------|
| 最小頻率 | 50ms |
| 最大頻率 | 5000ms |
| 預設頻率 | 100ms |

**Usage Example**:
```go
// Go client 訂閱賠率更新
stream, err := client.SubscribeOdds(ctx, &pb.SubscribeOddsRequest{
    RoundIds:            []string{"round-001", "round-002"}, // 過濾特定回合
    OverrideFrequencyMs: 200, // 覆蓋為 200ms
})
if err != nil {
    log.Fatal(err)
}

for {
    update, err := stream.Recv()
    if err == io.EOF {
        break // Stream 結束
    }
    if err != nil {
        log.Fatal(err)
    }

    switch update.UpdateType {
    case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ROUND_STARTED:
        log.Printf("Round %s started", update.RoundId)
    case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ODDS_CHANGE:
        log.Printf("Round %s odds: meron=%d wala=%d",
            update.RoundId, update.MeronOddsBps, update.WalaOddsBps)
    case pb.OddsUpdateType_ODDS_UPDATE_TYPE_ROUND_ENDED:
        log.Printf("Round %s ended: status=%s winner=%s",
            update.RoundId, update.RoundStatus, update.Winner)
    }
}
```

---

### Query (查詢)

#### GetCurrentOdds
查詢當前賠率。

```protobuf
rpc GetCurrentOdds(GetCurrentOddsRequest) returns (OddsResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `meron_odds_bps` | int32 | Meron 賠率 (基點) |
| `wala_odds_bps` | int32 | Wala 賠率 (基點) |

---

#### GetRiskExposure
查詢風險敞口。

```protobuf
rpc GetRiskExposure(GetRiskExposureRequest) returns (RiskExposureResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `current_exposure_cents` | int64 | 當前風險敞口 (分) |
| `max_allowed_cents` | int64 | 最大允許損失 (分) |
| `remaining_reserve_cents` | int64 | 剩餘額度 (分) |
| `is_at_risk` | bool | 是否處於高風險 (>80%) |
| `meron_pool_cents` | int64 | Meron 總投注 (分) |
| `wala_pool_cents` | int64 | Wala 總投注 (分) |
| `meron_bets` | int32 | Meron 投注數 |
| `wala_bets` | int32 | Wala 投注數 |
| `total_bets` | int32 | 總投注數 |
| `current_meron_odds_bps` | int32 | 當前 Meron 賠率 (基點) |
| `current_wala_odds_bps` | int32 | 當前 Wala 賠率 (基點) |
| `if_meron_wins_profit_cents` | int64 | 若 Meron 贏，莊家盈虧 (分) |
| `if_wala_wins_profit_cents` | int64 | 若 Wala 贏，莊家盈虧 (分) |

**假設性結算分析**:
`if_meron_wins_profit_cents` 和 `if_wala_wins_profit_cents` 提供即時的假設性結算預測，
可用於顯示「如果現在結束，莊家會...」的資訊。正值表示盈利，負值表示虧損。

---

### History Query (歷史查詢)

#### GetRoundHistory
查詢單一回合詳情（包含所有投注記錄）。

```protobuf
rpc GetRoundHistory(GetRoundHistoryRequest) returns (GetRoundHistoryResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `round_id` | string | Yes | 回合 ID |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `round` | RoundHistoryRecord | 回合記錄 |
| `bets` | repeated BetHistoryRecord | 該回合所有投注 |

**Errors**:
- `NOT_FOUND`: 回合不存在
- `INVALID_ROUND_ID`: round_id 格式無效

---

#### ListRounds
時間範圍查詢回合列表。

```protobuf
rpc ListRounds(ListRoundsRequest) returns (ListRoundsResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `start_time` | int64 | Yes | 開始時間 (Unix timestamp 秒) |
| `end_time` | int64 | Yes | 結束時間 (Unix timestamp 秒) |
| `status` | RoundStatusFilter | No | 狀態過濾 (UNSPECIFIED=不過濾) |
| `page_size` | int32 | No | 每頁筆數 (預設 20，最大 100) |
| `page_token` | string | No | 分頁 cursor token |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `rounds` | repeated RoundHistoryRecord | 回合列表 |
| `next_page_token` | string | 下一頁 token，空字串表示沒有更多 |
| `total_count` | int32 | 符合條件的總筆數 (首頁才計算) |

**Errors**:
- `INVALID_TIME_RANGE`: 時間範圍無效
- `INVALID_PAGE_TOKEN`: 分頁 token 無效

---

#### ListPlayerBets
查詢玩家投注歷史。

```protobuf
rpc ListPlayerBets(ListPlayerBetsRequest) returns (ListPlayerBetsResponse);
```

**Request**:
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `player_id` | string | Yes | 玩家 ID |
| `start_time` | int64 | No | 開始時間 (Unix timestamp 秒) |
| `end_time` | int64 | No | 結束時間 (Unix timestamp 秒) |
| `round_id` | string | No | 篩選特定回合 |
| `page_size` | int32 | No | 每頁筆數 (預設 20，最大 100) |
| `page_token` | string | No | 分頁 cursor token |

**Response**:
| 欄位 | 類型 | 說明 |
|------|------|------|
| `bets` | repeated BetHistoryRecord | 投注列表 |
| `next_page_token` | string | 下一頁 token |
| `total_count` | int32 | 符合條件的總筆數 |

**Errors**:
- `INVALID_PLAYER_ID`: player_id 為空
- `INVALID_PAGE_TOKEN`: 分頁 token 無效

---

#### RoundHistoryRecord
回合歷史記錄結構。

| 欄位 | 類型 | 說明 |
|------|------|------|
| `round_id` | string | 回合 ID |
| `status` | string | 狀態 (betting/stopped/settled/cancelled) |
| `winner` | string | 贏家 (meron/wala)，取消時為空 |
| `meron_pool_cents` | int64 | Meron 池 (分) |
| `wala_pool_cents` | int64 | Wala 池 (分) |
| `meron_net_income_cents` | int64 | Meron 淨收入 (分) |
| `wala_net_income_cents` | int64 | Wala 淨收入 (分) |
| `total_bets` | int32 | 總投注數 |
| `meron_bets` | int32 | Meron 投注數 |
| `wala_bets` | int32 | Wala 投注數 |
| `house_profit_cents` | int64 | 莊家盈利 (分) |
| `max_loss_cents` | int64 | 結算時最大損失 (分) |
| `final_meron_odds_bps` | int32 | 最終 Meron 賠率 (基點) |
| `final_wala_odds_bps` | int32 | 最終 Wala 賠率 (基點) |
| `started_at` | int64 | 開始時間 (Unix timestamp) |
| `ended_at` | int64 | 結束時間 (Unix timestamp)，0 表示尚未結束 |
| `max_round_loss_cents` | int64 | 配置的最大回合損失 (分) |
| `rake_rate_bps` | int32 | 抽水率 (基點) |

---

#### BetHistoryRecord
投注歷史記錄結構。

| 欄位 | 類型 | 說明 |
|------|------|------|
| `bet_id` | string | 投注 ID (bet_slug) |
| `round_id` | string | 回合 ID |
| `player_id` | string | 玩家 ID |
| `side` | string | 投注方 (meron/wala) |
| `status` | string | 狀態 (pending/confirmed/cancelled/expired) |
| `amount_cents` | int64 | 投注金額 (分) |
| `win_payout_cents` | int64 | 如果贏的賠付 (分) |
| `loss_income_cents` | int64 | 如果輸的收入 (分) |
| `original_odds_bps` | int32 | 原始賠率 (基點) |
| `adjusted_odds_bps` | int32 | 調整後賠率 (基點)，0 表示未調整 |
| `final_odds_bps` | int32 | 最終賠率 (基點) |
| `opponent_odds_bps` | int32 | 對手方賠率 (基點) |
| `bet_sequence` | int32 | 該回合第幾注 |
| `player_round_total_cents` | int64 | 玩家該回合累積投注 (分) |
| `created_at` | int64 | 創建時間 (Unix timestamp) |
| `confirmed_at` | int64 | 確認時間 (Unix timestamp)，0 表示未確認 |

---

#### RoundStatusFilter
回合狀態過濾枚舉。

| 值 | 說明 |
|------|------|
| `ROUND_STATUS_FILTER_UNSPECIFIED` | 不過濾，返回所有狀態 |
| `ROUND_STATUS_FILTER_BETTING` | 進行中 |
| `ROUND_STATUS_FILTER_SETTLED` | 已結算 |
| `ROUND_STATUS_FILTER_CANCELLED` | 已取消 |
| `ROUND_STATUS_FILTER_STOPPED` | 已暫停 |

---

## RiskConfig

風控配置。

| 欄位 | 類型 | 預設值 | 說明 |
|------|------|--------|------|
| `max_round_loss_cents` | int64 | 10000000 (100,000元) | 回合最大損失 |
| `min_bet_amount_cents` | int64 | 100000 (1,000元) | 單注最小額 |
| `max_bet_amount_cents` | int64 | 34500000 (345,000元) | 單注最大額 |
| `player_round_limit_cents` | int64 | 138000000 (1,380,000元) | 玩家回合限額 |
| `odds_floor_bps` | int32 | 3000 (0.30) | 賠率地板 |
| `odds_ceiling_bps` | int32 | 9900 (0.99) | 賠率天花板 |
| `rake_rate_bps` | int32 | 450 (4.5%) | 抽水率 |
| `auto_cancel_on_loss` | bool | false | **[已棄用]** 請改用 `unlimited_betting` |
| `accept_one_side_bet` | bool | false | 允許單邊投注 (false=單邊時自動取消) |
| `force_settle` | bool | false | 強制成局，StopRound 永不建議取消 |
| `unlimited_betting` | bool | false | **[v2.3.0]** 跳過風險容量檢查，按市場賠率全收 (見下方警告) |
| `initial_meron_odds_bps` | int32 | 8400 (0.84) | 開盤 Meron 賠率 (0=使用預設) |
| `initial_wala_odds_bps` | int32 | -10000 (-1.0) | 開盤 Wala 賠率 (0=使用預設) |
| `decay_config` | DecayConfig | 見下方 | **[v2.5.0]** 漸進式風險衰減配置 |

### DecayConfig (v2.5.0)

漸進式風險衰減，根據風險使用率動態調整賠率。

| 欄位 | 類型 | 預設值 | 說明 |
|------|------|--------|------|
| `enabled` | bool | true | 是否啟用漸進衰減 (false=回退到硬限制模式) |
| `mode` | DecayMode | EXPONENTIAL | 衰減模式 |
| `start_ratio_bps` | int32 | 5000 (50%) | 開始衰減的風險使用率 (0~9000) |
| `floor_bps` | int32 | 5000 (50%) | 賠率衰減地板，最低保留比例 (3000~10000) |
| `intensity_bps` | int32 | 3000 | 衰減強度，數值越大衰減越快 (1000~10000)，僅 EXPONENTIAL 模式 |
| `zones` | []DecayZone | - | 自定義分段區間，僅 PIECEWISE 模式，最多 5 個區間 |

**DecayMode 枚舉:**
| 值 | 說明 | 適用場景 |
|----|------|---------|
| `NONE` | 不衰減，僅硬限制 | 向後兼容 V1 行為 |
| `EXPONENTIAL` | 指數衰減，最平滑 | 推薦用於生產環境 |
| `QUADRATIC` | 二次曲線，前慢後快 | 需要前期容忍度 |
| `PIECEWISE` | 自定義分段線性 | 精確控制衰減曲線 |

**DecayZone 結構 (用於 PIECEWISE 模式):**
| 欄位 | 類型 | 說明 |
|------|------|------|
| `start_ratio_bps` | int32 | 區間起點 (基點, 包含) |
| `end_ratio_bps` | int32 | 區間終點 (基點, 不包含) |
| `start_decay_bps` | int32 | 起點的衰減因子 (10000 = 100% 不衰減) |
| `end_decay_bps` | int32 | 終點的衰減因子 |

**衰減公式 (EXPONENTIAL):**
```
risk_ratio = current_exposure / max_round_loss
if risk_ratio < start_ratio:
    decay = 1.0 (無衰減)
else:
    progress = (risk_ratio - start_ratio) / (1.0 - start_ratio)
    decay = floor + (1.0 - floor) * exp(-intensity * progress)
final_odds = market_odds * decay
```

### ⚠️ UnlimitedBetting 警告

當 `unlimited_betting = true` 時：

**行為說明**:
- 跳過風險容量檢查，所有投注都會被接受
- 使用純市場賠率（基於池子比例）
- **若市場賠率低於 floor (0.30)，仍提供 floor 賠率**
- 可能導致虧損超過 `max_round_loss`

**風險情境**:
```
情境: 極端單邊投注
- MeronPool = 500萬, WalaPool = 0
- 市場賠率: Meron = 0 (因為 Wala 池子為 0)
- 實際提供: Meron = 0.30 (floor)

後果:
- 繼續收 Meron 單，每筆賠付 = 投注額 × 0.30
- 若 Meron 贏: 總賠付 = 500萬 × 0.30 = 150萬
- 莊家虧損 = 150萬 (遠超 MaxRoundLoss)
```

**建議使用場景**:
- 重要賽事，業務需求必須成局
- 營運方明確接受無限風險
- 搭配 `force_settle = true` 使用（全收 + 一律成局）

### 配置驗證

StartRound 時會驗證 RiskConfig，若配置無效會返回 `INVALID_ARGUMENT` 錯誤：

| 驗證項目 | 規則 |
|---------|------|
| 金額限制 | `min_bet > 0`, `max_bet > 0`, `min_bet <= max_bet` |
| 賠率範圍 | `odds_floor` 和 `odds_ceiling` 在 1-9900 bps |
| 抽水率 | `rake_rate` 在 0-5000 bps (0-50%) |
| 開盤賠率 | 必須是有效馬來賠率 (3000-9900 或 -10000 到 -3000 bps) |
| 組合限制 | 不允許兩邊開盤賠率都是負數 |
| 最大虧損 | `max_round_loss >= 0` |

### 回合取消邏輯 (v2.3.0 更新)

**v2.3.0 更新**：`unlimited_betting` 和 `force_settle` 職責分離。

#### 參數職責

| 參數 | 影響階段 | 說明 |
|------|---------|------|
| `unlimited_betting` | EvaluateBet + ConfirmBet | 控制收單時是否跳過風險容量檢查 (v2.3.1: 同時影響兩階段) |
| `force_settle` | StopRound | 控制結算時是否建議取消 |

#### StopRound 取消評估邏輯

1. **`force_settle = true`**: `should_cancel = false`，跳過所有檢查
2. **`force_settle = false`** 時按順序檢查：
   - 若 `accept_one_side_bet = false` 且單邊投注：`should_cancel = true`
   - 若虧損超過 MaxRoundLoss：`should_cancel = true`

#### 組合行為

| unlimited_betting | force_settle | EvaluateBet | StopRound |
|-------------------|--------------|-------------|-----------|
| false | false | 嚴格風控，可能拒單 | 虧損超限或單邊時建議取消 |
| true | false | 全收，按市場賠率 | 虧損超限或單邊時建議取消 |
| true | true | 全收，按市場賠率 | 永不建議取消 |
| false | true | 嚴格風控，可能拒單 | 永不建議取消 |

#### StopRound 取消條件 (cancel_reason)

`force_settle = false` 時，StopRound 會評估以下條件並設定 `cancel_reason`:

| cancel_reason | 條件 | 說明 |
|--------------|------|------|
| `one_sided_betting` | `accept_one_side_bet=false` 且單邊投注 (MeronPool=0 或 WalaPool=0) | 單邊投注無對沖，結算即為莊家全輸或全贏 |
| `max_round_loss_exceeded` | `if_meron_wins_loss > max_round_loss` 或 `if_wala_wins_loss > max_round_loss` | 任一方勝利時的莊家虧損超過 MaxRoundLoss |

#### 組合情境說明

**情境 1: `unlimited_betting=false, force_settle=false` (預設保守)**
- EvaluateBet: 投注前檢查剩餘容量，超過 MaxRoundLoss 則調整賠率或拒絕
- StopRound: 評估風險，超限則建議取消
- 適用: 標準保守營運，控制單回合風險

**情境 2: `unlimited_betting=true, force_settle=false` (全收可取消)**
- EvaluateBet: 跳過風險容量檢查，按市場賠率接受所有投注
- StopRound: 仍評估風險，若累積投注導致潛在虧損超過 MaxRoundLoss 則建議取消
- 適用: 提升收單成功率，但保留結算前取消權利
- ⚠️ **風險**: 可能累積大量投注後被建議取消，影響玩家體驗

**情境 3: `unlimited_betting=true, force_settle=true` (最激進)**
- EvaluateBet: 跳過風險容量檢查，全收
- StopRound: 永不建議取消
- 適用: 完全接受市場風險，一律結算
- ⚠️ **風險**: 極端情況可能虧損遠超 MaxRoundLoss

**情境 4: `unlimited_betting=false, force_settle=true` (嚴格收單 + 強制結算)**
- EvaluateBet: 嚴格風控，可能拒絕高風險投注
- StopRound: 永不建議取消
- 適用: 投注階段嚴格把關，結算階段保證成局

**呼叫方流程**:
```
StopRound → 檢查 should_cancel → 若 true: CancelRound / 若 false: EndRound
```

**注意**: 呼叫方可以忽略 `should_cancel` 建議，強制調用 EndRound 或 CancelRound

---

## Error Handling (錯誤處理設計)

本服務使用**兩層錯誤處理設計**：

1. **gRPC Status Errors** - 系統層錯誤，表示請求無法處理
2. **Response rejection_reason** - 業務層拒絕，請求已處理但投注被拒

### 判斷準則

```
┌─────────────────────────────────────────────────────────────┐
│                      錯誤判斷流程                            │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  EvaluateBet Request                                        │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────┐                                        │
│  │ 參數驗證失敗?   │──Yes──▶ gRPC INVALID_ARGUMENT         │
│  └────────┬────────┘         (round_id/player_id/side 無效) │
│           │ No                                               │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ 回合不存在?     │──Yes──▶ gRPC NOT_FOUND                 │
│  └────────┬────────┘         (ROUND_NOT_FOUND)              │
│           │ No                                               │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ 回合狀態不對?   │──Yes──▶ Response: REJECTED             │
│  │ (已停止/已結束) │         rejection_reason: round_stopped│
│  └────────┬────────┘                                        │
│           │ No                                               │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ 金額不合規?     │──Yes──▶ Response: REJECTED             │
│  │ (過小/過大)     │         rejection_reason:              │
│  └────────┬────────┘         amount_too_small/amount_too_large│
│           │ No                                               │
│           ▼                                                  │
│  ┌─────────────────┐                                        │
│  │ 風險容量耗盡?   │──Yes──▶ Response: REJECTED             │
│  └────────┬────────┘         rejection_reason:              │
│           │ No               risk_capacity_exceeded          │
│           ▼                                                  │
│      Response: ACCEPTED / NEEDS_CONFIRMATION                 │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```

### 錯誤類型對比

| 層級 | 觸發條件 | 回應格式 | 客戶端處理 |
|------|---------|---------|------------|
| **gRPC Error** | 參數格式錯誤、資源不存在、系統錯誤 | `status.Code != OK` | 檢查錯誤碼，修正參數或重試 |
| **rejection_reason** | 業務規則拒絕 (金額/風險/狀態) | `status: REJECTED` + `rejection_reason` | 顯示拒絕原因給玩家 |

### gRPC Error Codes

#### 輸入驗證錯誤 (INVALID_ARGUMENT)

| Error Tag | 說明 |
|-----------|------|
| `INVALID_ROUND_ID` | round_id 無效或為空 |
| `INVALID_PLAYER_ID` | player_id 無效或為空 |
| `INVALID_SIDE` | side 未指定或無效 |
| `INVALID_BET_SLUG` | bet_slug 無效或為空 |
| `INVALID_AMOUNT` | amount 無效 (零或負數) |
| `INVALID_WINNER` | winner 無效 |
| `INVALID_PLAYER_CHOICE` | player_choice 無效 |
| `INVALID_CONFIG` | RiskConfig 驗證失敗 (參見配置驗證) |
| `INVALID_TIME_RANGE` | 時間範圍無效 (ListRounds) |
| `INVALID_PAGE_TOKEN` | 分頁 token 無效 |

#### 資源錯誤

| gRPC Code | Error Tag | 說明 |
|-----------|-----------|------|
| `NOT_FOUND` | `ROUND_NOT_FOUND` | 回合不存在 |
| `NOT_FOUND` | `BET_NOT_FOUND` | 投注不存在 |
| `ALREADY_EXISTS` | `ROUND_ALREADY_EXISTS` | 回合已存在 |

#### 業務邏輯錯誤 (FAILED_PRECONDITION)

| Error Tag | 說明 |
|-----------|------|
| `ROUND_NOT_ACTIVE` | 回合未啟用 (已結束或已取消) |
| `ROUND_STOPPED` | 回合已暫停，不接受新投注 |
| `ROUND_ALREADY_CANCELLED` | 回合已取消 |
| `ROUND_ALREADY_SETTLED` | 回合已結算 |

#### 權限與過期錯誤

| gRPC Code | Error Tag | 說明 |
|-----------|-----------|------|
| `PERMISSION_DENIED` | `PLAYER_MISMATCH` | ConfirmBet 時 player_id 不匹配 |
| `DEADLINE_EXCEEDED` | `BET_EXPIRED` | 評估已過期 (EvaluateBet 後超過 30 秒) |

#### 內部錯誤 (INTERNAL)

| Error Tag | 說明 |
|-----------|------|
| `INTERNAL_ERROR` | 伺服器內部錯誤 |
| `PERSISTENCE_FAILED` | 持久化失敗 |

### Rejection Reasons (業務拒絕原因)

當 EvaluateBet 返回 `status: REJECTED` 時，`rejection_reason` 欄位說明拒絕原因：

| rejection_reason | 說明 | 建議處理 |
|------------------|------|---------|
| `amount_too_small` | 投注金額低於最小限額 | 顯示最小投注額，讓玩家調整金額 |
| `amount_too_large` | 投注金額超過最大限額 | 顯示最大投注額，讓玩家調整金額 |
| `exceeds_round_limit` | 玩家回合累積投注超過限額 | 顯示剩餘可投注額度 |
| `odds_below_floor` | 計算後賠率低於地板值 | 告知玩家該方向賠率過低 |
| `risk_capacity_exceeded` | 風險容量已耗盡 (MaxRoundLoss) | 告知玩家該回合暫停收單 |
| `round_stopped` | 回合已暫停，不接受新投注 | 告知玩家投注時間已結束 |
| `round_cancelled` | 回合已取消 | 告知玩家回合已取消 |
| `buffer_risk_exceeded` | 批次風控：暫存風險過高 | 稍後重試 |
| `too_many_pending_bets` | 背壓：待處理投注過多 | 稍後重試 |

當 ConfirmBet 返回 `status: FAILED` 時，`failure_reason` 欄位說明失敗原因：

| failure_reason | 說明 |
|----------------|------|
| `round_stopped` | 確認時回合已暫停 |
| `token_expired` | 超時未確認 (> 30 秒) |
| `player_cancelled` | 玩家選擇取消 |

### 客戶端錯誤處理範例

```go
resp, err := client.EvaluateBet(ctx, req)
if err != nil {
    // gRPC 層錯誤
    st, _ := status.FromError(err)
    switch st.Code() {
    case codes.InvalidArgument:
        // 參數錯誤，修正後重試
        log.Printf("參數錯誤: %s", st.Message())
    case codes.NotFound:
        // 回合不存在
        log.Printf("回合不存在: %s", req.RoundId)
    case codes.Internal:
        // 伺服器錯誤，可重試
        log.Printf("伺服器錯誤，稍後重試")
    }
    return
}

// 檢查業務層拒絕
if resp.Status == pb.EvaluateBetResponse_REJECTED {
    // 業務規則拒絕，顯示原因給玩家
    switch resp.RejectionReason {
    case "amount_too_small":
        showError("投注金額過低，請增加金額")
    case "amount_too_large":
        showError("投注金額過高，請減少金額")
    case "risk_capacity_exceeded":
        showError("該回合暫停收單，請稍後再試")
    case "round_stopped":
        showError("投注時間已結束")
    default:
        showError("投注被拒絕: " + resp.RejectionReason)
    }
    return
}

// 投注被接受，繼續確認流程
if resp.Status == pb.EvaluateBetResponse_ACCEPTED {
    // 直接確認
} else if resp.Status == pb.EvaluateBetResponse_NEEDS_CONFIRMATION {
    // 顯示調整後賠率給玩家確認
}
```

---

## Usage Examples

### Go Client

```go
import (
    "context"
    pb "github.com/fatpit/risk-service/api/proto/risk/v1"
    "google.golang.org/grpc"
)

conn, _ := grpc.Dial("localhost:50051", grpc.WithInsecure())
client := pb.NewRiskServiceClient(conn)

// Start round
resp, _ := client.StartRound(ctx, &pb.StartRoundRequest{
    RoundId: "round-001",
    Config: &pb.RiskConfig{
        MaxRoundLossCents:      10000000,   // 100,000 元
        MinBetAmountCents:      100000,     // 1,000 元
        MaxBetAmountCents:      34500000,   // 345,000 元
        PlayerRoundLimitCents:  138000000,  // 1,380,000 元
        OddsFloorBps:           3000,       // 0.30
        OddsCeilingBps:         9900,       // 0.99
        RakeRateBps:            450,        // 4.5%
        InitialMeronOddsBps:    8000,       // 0.80 (自定義開盤，0=預設 0.84)
        InitialWalaOddsBps:     -9500,      // -0.95 (自定義開盤，0=預設 -1.0)
    },
})

// Evaluate bet
// 先查詢當前賠率顯示給玩家
oddsResp, _ := client.GetCurrentOdds(ctx, &pb.GetCurrentOddsRequest{RoundId: "round-001"})

evalResp, _ := client.EvaluateBet(ctx, &pb.EvaluateBetRequest{
    RoundId:         "round-001",
    PlayerId:        "player-001",
    Side:            pb.Side_SIDE_MERON,
    AmountCents:     1000000,  // 10,000 元
    BetSlug:         "bet-abc-123",
    ExpectedOddsBps: oddsResp.MeronOddsBps,  // 傳入顯示給玩家的賠率
})

// Confirm bet
confirmResp, _ := client.ConfirmBet(ctx, &pb.ConfirmBetRequest{
    RoundId:         "round-001",
    BetSlug:         evalResp.BetSlug,
    PlayerId:        "player-001",
    PlayerChoice:    pb.PlayerChoice_PLAYER_CHOICE_ACCEPT,
    AcceptedOddsBps: evalResp.OriginalOddsBps,
})

// End round
endResp, _ := client.EndRound(ctx, &pb.EndRoundRequest{
    RoundId: "round-001",
    Winner:  pb.Side_SIDE_MERON,
})
```

### Ruby Client (Rails)

```ruby
require 'risk/v1/risk_service_services_pb'

stub = Risk::V1::RiskService::Stub.new('localhost:50051', :this_channel_is_insecure)

# Start round
resp = stub.start_round(Risk::V1::StartRoundRequest.new(
  round_id: 'round-001',
  config: Risk::V1::RiskConfig.new(
    max_round_loss_cents: 10_000_000,
    min_bet_amount_cents: 100_000,       # 1,000 元
    max_bet_amount_cents: 34_500_000,
    player_round_limit_cents: 138_000_000, # 1,380,000 元
    odds_floor_bps: 3000,
    odds_ceiling_bps: 9900,
    rake_rate_bps: 450,
    initial_meron_odds_bps: 8000,         # 自定義開盤 0.80 (0=預設 0.84)
    initial_wala_odds_bps: -9500          # 自定義開盤 -0.95 (0=預設 -1.0)
  )
))

# 先查詢賠率顯示給玩家
odds_resp = stub.get_current_odds(Risk::V1::GetCurrentOddsRequest.new(round_id: 'round-001'))

# Evaluate bet (傳入顯示給玩家的賠率)
eval_resp = stub.evaluate_bet(Risk::V1::EvaluateBetRequest.new(
  round_id: 'round-001',
  player_id: 'player-001',
  side: :SIDE_MERON,
  amount_cents: 1_000_000,
  bet_slug: 'bet-abc-123',
  expected_odds_bps: odds_resp.meron_odds_bps  # 傳入顯示給玩家的賠率
))

# Convert bps to float for display
odds = eval_resp.original_odds_bps / 10000.0  # => 0.78
```

### grpcurl (CLI)

```bash
# Start round (with custom opening odds)
grpcurl -plaintext -d '{
  "round_id": "test-001",
  "config": {
    "max_round_loss_cents": 10000000,
    "rake_rate_bps": 450,
    "initial_meron_odds_bps": 8000,
    "initial_wala_odds_bps": -9500
  }
}' localhost:50051 risk.v1.RiskService/StartRound

# Get current odds (顯示給玩家)
grpcurl -plaintext -d '{"round_id": "test-001"}' \
  localhost:50051 risk.v1.RiskService/GetCurrentOdds

# Evaluate bet (帶入顯示給玩家的賠率)
grpcurl -plaintext -d '{
  "round_id": "test-001",
  "player_id": "player-001",
  "side": "SIDE_MERON",
  "amount_cents": 1000000,
  "bet_slug": "bet-abc-123",
  "expected_odds_bps": 8400
}' localhost:50051 risk.v1.RiskService/EvaluateBet

# Get round state
grpcurl -plaintext -d '{"round_id": "test-001"}' \
  localhost:50051 risk.v1.RiskService/GetRoundState

# End round
grpcurl -plaintext -d '{"round_id": "test-001", "winner": "SIDE_MERON"}' \
  localhost:50051 risk.v1.RiskService/EndRound

# ==================== Stop Round (v2.2.0 新流程) ====================

# Stop round (永久結束下注，返回風險評估)
grpcurl -plaintext -d '{"round_id": "test-001", "reason": "betting_closed"}' \
  localhost:50051 risk.v1.RiskService/StopRound
# 回應範例:
# {
#   "stoppedAt": "1734566400",
#   "pendingBetsExpired": 2,
#   "provisionalRiskReleasedCents": "500000",
#   "confirmedBets": 15,
#   "meronBets": 8,
#   "walaBets": 7,
#   "meronPoolCents": "5000000",
#   "walaPoolCents": "3000000",
#   "meronNetIncomeCents": "225000",
#   "walaNetIncomeCents": "135000",
#   "currentMeronOddsBps": 7800,
#   "currentWalaOddsBps": -7800,
#   "ifMeronWinsProfitCents": "-1500000",
#   "ifWalaWinsProfitCents": "2000000",
#   "shouldCancel": false,
#   "isOneSided": false,
#   "meronWinsExceedsMaxLoss": false,
#   "walaWinsExceedsMaxLoss": false,
#   "maxExposureCents": "1500000",
#   "maxAllowedCents": "10000000",
#   "startedAt": "1734566000",
#   "minBetAmountCents": "100000",
#   "maxBetAmountCents": "34500000",
#   "rakeRateBps": 450,
#   "oddsFloorBps": 3000,
#   "oddsCeilingBps": 9900
# }

# 若回應 shouldCancel=true，根據 cancelReason 決定取消
# grpcurl -plaintext -d '{"round_id": "test-001", "reason": "one_sided_betting"}' \
#   localhost:50051 risk.v1.RiskService/CancelRound

# ==================== History Query ====================

# Get round history (單一回合詳情)
grpcurl -plaintext -d '{"round_id": "test-001"}' \
  localhost:50051 risk.v1.RiskService/GetRoundHistory

# List rounds (時間範圍查詢)
grpcurl -plaintext -d '{
  "start_time": 1734480000,
  "end_time": 1734566400,
  "status": "ROUND_STATUS_FILTER_SETTLED",
  "page_size": 20
}' localhost:50051 risk.v1.RiskService/ListRounds

# List player bets (玩家投注歷史)
grpcurl -plaintext -d '{
  "player_id": "player-001",
  "page_size": 50
}' localhost:50051 risk.v1.RiskService/ListPlayerBets

# List player bets with filters
grpcurl -plaintext -d '{
  "player_id": "player-001",
  "round_id": "test-001",
  "start_time": 1734480000,
  "end_time": 1734566400
}' localhost:50051 risk.v1.RiskService/ListPlayerBets
```
