# Cross-Matching Specification

Synthetic matching for multi-runner markets with configurable risk tolerance.

## Overview

Cross-matching allows the exchange to fill user orders by creating offsetting positions on other runners. The exchange uses an **internal house account** to execute hedge legs, ensuring worst-case outcome P&L stays within configured tolerance (`P&L >= -slack`).

## Status

**Implemented module** in `src/cross_match/` and orchestrated above `Book`.

- Not auto-invoked by the core engine loop
- Current algorithm scope: exactly 3-runner markets
- Supports compensation via `VoidTradesFromTime` when a hedge leg fails after partial execution

## Core Principle

For a **multi-runner, mutually exclusive** market (exactly one outcome wins), the exchange may synthetically match a user order on runner **A** only if it can place offsetting orders on the **other runners** such that the **exchange's worst-case P&L is inside risk tolerance**.

When tolerance is set to zero, this is strict risk-free matching.

---

## Definitions

| Term | Definition |
|------|------------|
| Runners | Outcomes `1..N` (mutually exclusive, exactly one wins) |
| User order | On runner `A` with odds `a` and stake `s` |
| Hedge legs | Exchange's offsetting orders on runners `j ≠ A` |
| `L` | Exchange liability if A wins: `L = s × (a - 1)` |
| `o[j]` | Best available opposing price on runner `j` used for hedge legs |
| `t[j]` | Hedge stake on runner `j` |

### P&L Formulas

**BACK** with odds `o` and stake `s`:
- Runner wins: profit = `+s × (o - 1)`
- Runner loses: profit = `-s`

**LAY** with odds `o` and stake `s`:
- Runner wins: profit = `-s × (o - 1)` (liability)
- Runner loses: profit = `+s`

---

## Risk-Free Cross-Match Pattern

### User Places: BACK A @ a for stake s

**Step 1: Exchange takes the other side**
```
Exchange internal account: LAY A @ a stake s
(matched against user's BACK)
```

**Step 2: Exchange hedges by laying other runners**
```
For each j ≠ A:
  Exchange internal account: LAY j @ o[j] stake t[j]
  (matched against existing BACK liquidity on runner j)
```

### Why "Lay the Other Runners"?

If **A wins**, all other runners lose. The exchange wins stakes `t[j]` from laying them — this funds the payout to the user.

---

## Risk-Free Constraints

### If A Wins

Exchange loses `L` on user bet, wins `t[j]` on all other runners:

```
P_A = -L + Σ t[j]  for all j ≠ A
```

Constraint: `P_A ≥ 0` requires `Σ t[j] ≥ L`

### If Runner k Wins (k ≠ A)

- User loses → exchange wins `+s`
- Hedge on k loses → `-t[k] × (o[k] - 1)`
- All other hedges win → `+t[j]` for j ≠ A, j ≠ k

```
P_k = s + Σ t[j] - t[k] × (o[k] - 1)  for j ≠ A, j ≠ k
```

Constraint: `P_k ≥ 0` for all k ≠ A

### Per-Runner Maximum Safe Hedge

Rearranging `P_k ≥ 0`:

```
t[k] ≤ (s + Σ t[j]) / (o[k] - 1)  for j ≠ A, j ≠ k
```

Each hedge leg has a **maximum safe size** given the others.

---

## 3-Runner Algorithm (v1)

For a market with runners A, B, C:

### Inputs

- User: runner A, odds `a`, stake `s`
- Market: top-of-book hedge prices/liquidity on the 2 other runners
- Tolerance: `slack = effective_tolerance(stake)`

### Algorithm

```
function calculate_3runner_hedge(input):
    validate 2 hedge runners and odds > 1.0
    derive integer constraints in money quanta

    // Search one dimension (tB or tC), derive feasible bound for the other leg.
    // Use bounded stride scan + local refinement to cap runtime on deep ladders.
    for candidate in bounded_scan:
        derive companion leg min/max from constraints
        if feasible and worst_pnl >= -slack:
            keep best candidate

    return SUCCESS(legs, worst_case_pnl) or NoSolution
```

Implementation notes:
- Pure integer math (no float hedge splits)
- Search density is capped (`MAX_HEDGE_SCAN_POINTS`)
- LAY-side algorithm accepts zero-hedge solutions when unhedged worst-case already satisfies tolerance

---

## Worked Example

### Setup

```
Market: Man Utd vs Chelsea (1X2)
  R1 = Man Utd (A)
  R2 = Draw (B)
  R3 = Chelsea (C)

User order: BACK Man Utd @ 2.00 for $100

Available BACK liquidity (exchange can LAY against):
  Draw:    4.00 @ $200
  Chelsea: 4.00 @ $200
```

### Calculation

```
Liability if Man Utd wins: L = 100 × (2.00 - 1) = $100

Try symmetric hedge: tB = 50, tC = 50 (sum = 100 = L)
```

### Constraint Check

**If B wins (Draw):**
```
tB ≤ (s + tC) / (oB - 1)
50 ≤ (100 + 50) / (4.00 - 1)
50 ≤ 150 / 3
50 ≤ 50  ✓
```

**If C wins (Chelsea):**
```
tC ≤ (s + tB) / (oC - 1)
50 ≤ (100 + 50) / (4.00 - 1)
50 ≤ 50  ✓
```

### P&L Verification

| Outcome | User Bet | Hedge B | Hedge C | Total |
|---------|----------|---------|---------|-------|
| Man Utd wins | -$100 | +$50 | +$50 | **$0** |
| Draw | +$100 | -$150 | +$50 | **$0** |
| Chelsea wins | +$100 | +$50 | -$150 | **$0** |

✅ Risk-free in every outcome.

### Orders Executed (Leg Plan)

```
1. Match user order:
   Exchange internal: LAY Man Utd @ 2.00 stake $100

2. Hedge legs:
   Exchange internal: LAY Draw @ 4.00 stake $50
   Exchange internal: LAY Chelsea @ 4.00 stake $50
```

---

## When Cross-Matching Fails

Many real price combinations have **no risk-free solution**.

Example with `A=2.14`, `B=3.40`, `C=4.20`:

```
L = 100 × 1.14 = $114

Constraint check reveals no valid (tB, tC) exists where:
  - tB + tC ≥ 114
  - tB ≤ (100 + tC) / 2.40
  - tC ≤ (100 + tB) / 3.20
```

**v1 Response:** "No feasible hedge within tolerance. Order rests as unmatched."

Betfair accepts residual risk because they have portfolio flow and earn on average. In this implementation, accepted risk is bounded by configured tolerance.

---

## N-Runner Generalization

For N outcomes with user BACK on A:

Find `t[j] ≥ 0` for all `j ≠ A` such that:

1. `Σ t[j] ≥ L`
2. For every `k ≠ A`: `s + Σ t[j] - t[k] × (o[k] - 1) ≥ 0` for j ≠ A, j ≠ k

### v1 Scope

- **3-runner markets only** (soccer 1X2)
- N-runner (horse racing) deferred to v2 with LP solver

---

## Runtime Contract

Current entrypoint:

```rust
CrossMatchEngine::attempt_cross_match(&mut Book, user_order_id) -> CrossMatchResult
```

Execution model:

1. Validate target order exists and is still resting.
2. Enforce runner constraints in this order:
   - `runners.len() > max_runners` -> `NotPossible("Too many runners for cross-matching")`
   - `runners.len() != 3` -> `NotPossible("Cross-matching only supported for 3-runner markets")`
3. Build hedge input from top-of-book prices/liquidity on the other two runners.
4. Run hedge solver and filter out zero-stake hedge legs before command submission.
5. Build house commands:
   - First leg: take opposite side on target runner
   - Remaining legs: hedge runners
   - All legs use `TimeInForce::FillOrKill { min_fill: None }`
6. Execute legs sequentially (`book.handle`, then `book.apply_all_events`).
7. If a leg fails after partial execution, issue best-effort compensation with:
   - `CommandKind::VoidTradesFromTime { from_matched_at_inclusive: first_trade_ts, reason: "Cross-match hedge leg failed" }`

House account:

- Configurable via `CrossMatchConfig.house_account` (default nil UUID string via `AccountId::from(Uuid::nil())`).

Result model:

- `CrossMatchResult::Success { events, hedge_legs, worst_case_pnl }`
- `CrossMatchResult::NotPossible { reason }`
- `CrossMatchResult::Failed { reason, partial_events }`

`partial_events` may include both matched trade events and compensation void marker events.
Failure reasons distinguish plain hedge rejection vs. hedge rejection plus compensation-void failure.

## Events

Cross-match uses standard book events only (`OrderAccepted`, `TradeMatched`, `VoidTradesFromTime`, etc.).
The cross-match module does not introduce a new on-ledger event type.

---

## Settlement

Cross-matched trades settle like any other:

1. Each leg settles independently based on runner result
2. House account P&L aggregates across all legs
3. By construction, house worst-case P&L is bounded by configured tolerance

Example settlement (Man Utd wins):

| Trade | Account | Runner | Side | Stake | P&L |
|-------|---------|--------|------|-------|-----|
| T1 | User | Man Utd | Back | $100 | +$100 |
| T1 | House | Man Utd | Lay | $100 | -$100 |
| T2 | House | Draw | Lay | $50 | +$50 |
| T3 | House | Chelsea | Lay | $50 | +$50 |
| | | | **House Total** | | **$0** |

---

## Atomicity Requirement

Current behavior is **execute-then-compensate** (best effort), not transactional rollback:

- Legs are submitted sequentially.
- On mid-sequence failure, the module attempts compensation via `VoidTradesFromTime`.
- Failure result includes `partial_events` so callers can persist/inspect exactly what happened.

Callers that require stricter atomicity should orchestrate journal/commit policy around these returned events.

---

## Configuration

```rust
pub struct CrossMatchConfig {
    /// Maximum runners supported (v1: 3)
    pub max_runners: usize,

    /// House account for hedge legs
    pub house_account: AccountId,

    /// Risk tolerance settings
    pub risk: RiskTolerance,

    /// Whether to attempt cross-match on partial fills
    pub cross_match_remainder: bool,
}

pub struct RiskTolerance {
    /// Maximum loss as percentage of user's stake.
    /// 0.0 = risk-free only
    /// 0.05 = allow up to 5% loss relative to stake
    pub max_loss_pct: Decimal,

    /// Absolute cap on loss per cross-match (regardless of percentage).
    /// Prevents runaway losses on large bets.
    pub max_loss_absolute: Money,

    /// Maximum aggregate exposure across all unsettled cross-matches.
    /// Circuit breaker - reject new cross-matches if breached.
    pub max_total_exposure: Money,
}

impl Default for RiskTolerance {
    fn default() -> Self {
        Self {
            max_loss_pct: Decimal::ZERO,        // Risk-free
            max_loss_absolute: Money(0),         // No absolute tolerance
            max_total_exposure: Money(0),        // Disabled until configured
        }
    }
}

impl RiskTolerance {
    /// Conservative preset: essentially risk-free with tiny tolerance for rounding
    pub fn conservative() -> Self {
        Self {
            max_loss_pct: Decimal::new(1, 4),    // 0.01% (rounding tolerance only)
            max_loss_absolute: Money::from_cents(100),     // $1 max
            max_total_exposure: Money::from_cents(10_000), // $100 aggregate
        }
    }

    /// Moderate preset: small tolerance to improve fill rates
    pub fn moderate() -> Self {
        Self {
            max_loss_pct: Decimal::new(1, 2),    // 1%
            max_loss_absolute: Money::from_cents(10_000),   // $100 max per match
            max_total_exposure: Money::from_cents(100_000), // $1,000 aggregate
        }
    }
}
```

### Effective Loss Tolerance

Per-match tolerance is the **minimum** of percentage and absolute:

```
effective_tolerance(stake) = min(
    stake * max_loss_pct,
    max_loss_absolute
)
```

### Risk Constraint

With tolerance, the constraint changes from:

```
P_k >= 0  for all outcomes k
```

To:

```
P_k >= -effective_tolerance(stake)  for all outcomes k
```

In the algorithm:

```
let slack = min(stake * config.risk.max_loss_pct, config.risk.max_loss_absolute)

// Constraint check with tolerance
if tB <= (s + tC + slack)/(oB-1) AND tC <= (s + tB + slack)/(oC-1):
    return SUCCESS
```

### Aggregate Exposure Tracking

Before attempting a cross-match, check:

```
if current_exposure + worst_case_loss > max_total_exposure:
    return REJECT  // Circuit breaker tripped
```

**Implementation note:** Aggregate exposure must be tracked durably (persisted) to survive crashes. Options:
- Store in journal alongside events
- Rebuild from unsettled cross-match records on startup
- Use separate exposure ledger with WAL

This is deferred to implementation phase.

**Trade-off:** Higher tolerance = more fills, but potential losses. Track actual P&L over time to tune.

---

## Limitations (v1)

1. **3-runner markets only** - N-runner requires LP solver
2. **Aggregate exposure breaker not enforced yet** (`max_total_exposure` is configured but TODO in runtime)
3. **No commission in hedge calc** - apply conservatively in tolerance
4. **Best-effort compensation model** - not a hard transactional rollback layer

---

## Future Enhancements (v2+)

1. N-runner support with LP solver
2. Partial cross-matching
3. Commission-aware hedge calculation
4. Cross-match against implied liquidity (recursive)
5. Dynamic risk tolerance based on market conditions

---

## References

- Betfair cross-matching uses internal "Betfair SP" account
- Similar to market-maker delta hedging in traditional finance
