# SYSTEM_DESIGN.md — TipSharks Predictive Decision Engine

> Complete system design specification for validation against implementation.
> Companion to PLAN.md. This doc defines contracts, algorithms, interfaces, and acceptance criteria.
> Created: 2026-06-22. Status: DRAFT — validation spec.

---

## Table of Contents
1. [Scope & Definitions](#1-scope--definitions)
2. [System Context](#2-system-context)
3. [Data Contracts](#3-data-contracts)
4. [Algorithm Specifications](#4-algorithm-specifications)
5. [API Contracts](#5-api-contracts)
6. [Interface Contracts (UI)](#6-interface-contracts-ui)
7. [Integration Contracts](#7-integration-contracts)
8. [Non-Functional Requirements](#8-non-functional-requirements)
9. [Validation Criteria](#9-validation-criteria)
10. [Test Specifications](#10-test-specifications)
11. [Glossary](#11-glossary)

---

## 1. Scope & Definitions

### 1.1 In Scope
- Data ingestion extensions (tab-api-ingest Prisma + external weather)
- Feature engineering pipeline (speed figures, pace ratings, track bias, weight norm)
- ML ranking models (XGBoost/LightGBM)
- Monte Carlo simulation engine (10,000 runs/race)
- Value & odds rules (overlay/underlay detection, Kelly)
- Portfolio optimizer (exotic bets, budget-constrained)
- UI overhaul (speed maps, confidence curves, ticket builder, value alerts)
- Web UI parity enhancements

### 1.2 Out of Scope (v1)
- Stride length/frequency data (no source)
- Real-time sectional tracking during live race
- Multi-week planner heuristics
- Multi-lineup portfolio (use risk profiles instead)
- Public model accuracy dashboard

### 1.3 Definitions
| Term | Definition |
|------|-----------|
| True Pace | Speed figure normalized for track bias, moisture, wind |
| Closing Burst | Late pace rating from closing 600m sectional |
| Settling Line | Expected early running position from `speedmap.settling_lengths` |
| Overlay | Model probability > market-implied probability (value bet) |
| Underlay | Model probability < market-implied probability (market trap) |
| Kelly Fraction | `f* = (bp - q) / b` where b=odds-1, p=model prob, q=1-p |
| Risk Profile | Conservative (win/place), Balanced (quinella/exacta), Aggressive (trifecta/first-four) |
| DNF Layer | Harness break-stride probability sampling in Monte Carlo |
| 90% CI | 5th–95th percentile range from Monte Carlo distribution |
| Feature Vector | Engineered inputs to ML model per (horse, race) pair |

---

## 2. System Context

### 2.1 Service Boundaries
```
tab-api-ingest (TS)  ──HTTP──>  tipsharks-elo-api (Python)  ──HTTP──>  tipsharks-client (RN + FastAPI)
   TAB API ingest                    ML + Sim + Optimizer                    Mobile UI
   External weather                  REST API + WebSocket                    Backend proxy + cache
   BullMQ workers                    Bootstrap web UI                        MongoDB + notifications
```

### 2.2 Data Flow
```
TAB API ──> tab-api-ingest (Prisma) ──> elo-api (PostgreSQL + features + ML + sim + optimizer)
                                          │
                                          ├──> REST API ──> client backend ──> mobile UI
                                          ├──> WebSocket ──> client (live sim progress)
                                          └──> Web UI (Bootstrap)
External weather API ──> tab-api-ingest (WeatherSnapshot) ──> elo-api features
HRNZ stewards ──> hrnz_scraper ──> elo-api (break-stride rates)
```

### 2.3 Ownership Matrix
| Concern | Owner Service | Storage |
|---------|--------------|---------|
| Raw TAB data ingest | tab-api-ingest | PostgreSQL (Prisma) |
| Weather data ingest | tab-api-ingest | PostgreSQL (WeatherSnapshot) |
| Elo ratings | elo-api | PostgreSQL (rating_snapshots) |
| Feature engineering | elo-api | PostgreSQL (speed_figures, pace_ratings, feature_vectors) |
| ML models | elo-api | PostgreSQL (ml_models) + model files |
| Monte Carlo simulation | elo-api | PostgreSQL (simulation_results) |
| Value bets | elo-api | PostgreSQL (value_bets) |
| Portfolio optimization | elo-api | PostgreSQL (portfolio_recommendations) |
| Mobile UI state | tipsharks-client | MongoDB (cache) + Zustand |
| Notifications | tipsharks-client | MongoDB + Twilio/SendGrid/Resend |

---

## 3. Data Contracts

### 3.1 tab-api-ingest — Prisma Schema Extensions

#### 3.1.1 Race Model — New Fields
```prisma
model Race {
  // ... existing fields ...
  trackDirection      VarChar(10)?    // "Left" | "Right" | null
  railPosition        VarChar(100)?   // e.g. "True Entire Circuit" | null
  trackCircumference  Int?            // meters | null
  trackHomeStraight   Int?            // meters | null
  startType           VarChar(20)?    // "mobile" | "standing" | null (harness)
  gait                VarChar(20)?    // "pace" | "trot" | null (harness)
  trackSurface        VarChar(20)?    // "turf" | "synthetic" | "dirt" | null
  actualStart         Timestamptz?    // actual start timestamp | null
}
```
**Validation:** All nullable. `startType` and `gait` required for category='H' (harness) races post-migration. `trackDirection` enum ∈ {null, "Left", "Right"}.

#### 3.1.2 Runner Model — New Fields
```prisma
model Runner {
  // ... existing fields ...
  gear                Json?           // ["Blinkers", "Tongue Tie"] | null
  speedmapSettling    Int?            // settling_lengths from speedmap | null
  favourite            Boolean         @default(false)
  mover                Boolean         @default(false)
  deduction            Json?           // {win: Decimal(10,2), place: Decimal(10,2)} | null
  flucs                Json?           // [{odds: Decimal(10,2)}] | null
  flucsTimeline        Json?           // [{odds: Decimal(10,2), timestamp: Timestamptz}] | null
  formIndicators      Json?           // [{name: String, group: String, negative: Boolean, priority: Int}] | null
  prizeMoney          VarChar(100)?   // career earnings string | null
  allowanceWeight     Decimal(4,2)?   // jockey claim kg | null
  silkUrl64           VarChar(255)?
  silkUrl128          VarChar(255)?
}
```
**Validation:** `favourite`/`mover` default false. `gear` is array of strings or null. `deduction` is object with `win`/`place` decimals or null. `flucsTimeline` entries sorted by timestamp ascending.

#### 3.1.3 Result Model — New Fields
```prisma
model Result {
  // ... existing fields ...
  mileRate400         Decimal(10,3)?  // last 400m sectional seconds | null
  mileRate800         Decimal(10,3)?  // last 800m sectional seconds | null
  sectionalTimes      Json?           // {opening_400: Decimal, closing_600: Decimal, ...} | null
}
```
**Validation:** `mileRate400`/`mileRate800` > 0 when present. `sectionalTimes` keys ∈ {"opening_400", "closing_600", "opening_800", "mile_rate"}.

#### 3.1.4 New Model: WeatherSnapshot
```prisma
model WeatherSnapshot {
  id              String   @id @default(uuid())
  raceId          String
  race            Race     @relation(fields: [raceId], references: [id], onDelete: Cascade)
  temperature     Decimal(5,2)?  // Celsius | null
  windSpeed       Decimal(5,2)?  // km/h | null
  windDirection   VarChar(10)?   // degrees (0-360) or cardinal (N/NE/...) | null
  moistureReading Decimal(5,2)? // penetrometer reading | null
  capturedAt      Timestamptz
  source          VarChar(50)    // "BOM" | "OpenWeatherMap" | "manual"

  @@index([raceId])
}
```
**Validation:** One WeatherSnapshot per race per source per capture time. `temperature` ∈ [-50, 60]. `windSpeed` >= 0. `moistureReading` >= 0.

### 3.2 tipsharks-elo-api — SQLAlchemy Schema Extensions

#### 3.2.1 New Table: speed_figures
```python
class SpeedFigure(Base):
    __tablename__ = "speed_figures"
    id            = Column(Integer, primary_key=True)
    horse_id      = Column(Integer, ForeignKey("horses.id"), nullable=False)
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=False)
    figure        = Column(Numeric(6, 2), nullable=False)      # normalized speed figure
    raw_time      = Column(Numeric(10, 3), nullable=True)      # raw race time seconds
    track_bias_adj = Column(Numeric(6, 2), nullable=True)      # track bias adjustment
    moisture_adj  = Column(Numeric(6, 2), nullable=True)       # moisture adjustment
    wind_adj      = Column(Numeric(6, 2), nullable=True)       # wind adjustment
    weight_adj    = Column(Numeric(6, 2), nullable=True)       # weight normalization adjustment
    computed_at   = Column(DateTime, nullable=False)
    __table_args__ = (UniqueConstraint("horse_id", "race_id"),)
```
**Validation:** `figure` ∈ [0, 200] (typical speed figure range). One row per (horse, race). `computed_at` <= race start time + 24h.

#### 3.2.2 New Table: pace_ratings
```python
class PaceRating(Base):
    __tablename__ = "pace_ratings"
    id            = Column(Integer, primary_key=True)
    horse_id      = Column(Integer, ForeignKey("horses.id"), nullable=False)
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=False)
    early_pace   = Column(Numeric(6, 2), nullable=False)      # gate speed rating
    late_pace    = Column(Numeric(6, 2), nullable=False)      # closing burst rating
    settling_line = Column(Integer, nullable=True)             # expected running position (1=lead)
    computed_at   = Column(DateTime, nullable=False)
    __table_args__ = (UniqueConstraint("horse_id", "race_id"),)
```
**Validation:** `early_pace`/`late_pace` ∈ [0, 200]. `settling_line` >= 1.

#### 3.2.3 New Table: track_bias_profiles
```python
class TrackBiasProfile(Base):
    __tablename__ = "track_bias_profiles"
    id            = Column(Integer, primary_key=True)
    venue         = Column(String(255), nullable=False)
    start_type    = Column(String(20), nullable=True)
    distance_bucket = Column(String(20), nullable=False)       # "sprint"|"mile"|"middle"|"staying"
    bias_value    = Column(Numeric(6, 2), nullable=False)      # + favors front-runners, - favors closers
    sample_count  = Column(Integer, nullable=False, default=0)
    updated_at    = Column(DateTime, nullable=False)
    __table_args__ = (UniqueConstraint("venue", "start_type", "distance_bucket"),)
```
**Validation:** `bias_value` ∈ [-10, 10]. `sample_count` >= 0.

#### 3.2.4 New Table: feature_vectors
```python
class FeatureVector(Base):
    __tablename__ = "feature_vectors"
    id            = Column(Integer, primary_key=True)
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=False)
    horse_id      = Column(Integer, ForeignKey("horses.id"), nullable=False)
    features      = Column(JSONB, nullable=False)              # dict of feature name -> value
    version       = Column(String(50), nullable=False)         # feature schema version
    computed_at   = Column(DateTime, nullable=False)
    __table_args__ = (UniqueConstraint("race_id", "horse_id", "version"),)
```
**Validation:** `features` is JSON object. Required keys (v1): `elo_rating`, `speed_figure`, `early_pace`, `late_pace`, `barrier`, `weight`, `track_bias`, `moisture`, `wind_speed`, `jockey_elo`, `trainer_elo`, `days_since_last_run`, `form_score`.

#### 3.2.5 New Table: ml_models
```python
class MLModel(Base):
    __tablename__ = "ml_models"
    id            = Column(String(50), primary_key=True)       # UUID
    version       = Column(String(50), nullable=False)        # semantic version
    model_type    = Column(String(50), nullable=False)         # "xgboost"|"lightgbm"|"catboost"
    target        = Column(String(50), nullable=False)         # "finishing_order"|"win_prob"
    trained_at    = Column(DateTime, nullable=False)
    accuracy      = Column(Numeric(6, 4), nullable=True)       # validation accuracy
    features      = Column(JSONB, nullable=False)              # list of feature names
    hyperparams   = Column(JSONB, nullable=True)
    model_path    = Column(String(500), nullable=False)        # file path to serialized model
    is_active     = Column(Boolean, nullable=False, default=False)
```
**Validation:** Exactly one `is_active=True` per (target, model_type). `accuracy` ∈ [0, 1].

#### 3.2.6 New Table: simulation_results
```python
class SimulationResult(Base):
    __tablename__ = "simulation_results"
    id            = Column(Integer, primary_key=True)
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=False, unique=True)
    run_count     = Column(Integer, nullable=False)            # e.g. 10000
    seed          = Column(BigInteger, nullable=False)         # RNG seed for reproducibility
    win_dist      = Column(JSONB, nullable=False)              # {horse_id: win_count}
    place_dist    = Column(JSONB, nullable=False)              # {horse_id: {1: n, 2: n, 3: n}}
    exotic_dist   = Column(JSONB, nullable=False)             # {exacta: {"h1-h2": n}, trifecta: {...}, ...}
    ci_lower      = Column(JSONB, nullable=False)             # {horse_id: 5th percentile win prob}
    ci_upper      = Column(JSONB, nullable=False)             # {horse_id: 95th percentile win prob}
    percentiles   = Column(JSONB, nullable=False)             # {horse_id: {p5,p25,p50,p75,p95}}
    dnf_rate      = Column(JSONB, nullable=True)              # {horse_id: dnf_count} (harness)
    computed_at   = Column(DateTime, nullable=False)
```
**Validation:** `run_count` == sum of `win_dist` values. `seed` reproducible (same seed → same dist). `ci_lower[h] <= percentiles[h]['p50'] <= ci_upper[h]` for all h.

#### 3.2.7 New Table: value_bets
```python
class ValueBet(Base):
    __tablename__ = "value_bets"
    id            = Column(Integer, primary_key=True)
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=False)
    horse_id      = Column(Integer, ForeignKey("horses.id"), nullable=False)
    model_prob    = Column(Numeric(6, 4), nullable=False)      # from simulation
    market_prob   = Column(Numeric(6, 4), nullable=False)      # implied from odds
    market_odds   = Column(Numeric(10, 2), nullable=False)
    edge          = Column(Numeric(6, 4), nullable=False)      # model_prob - market_prob
    kelly_fraction = Column(Numeric(6, 4), nullable=False)     # f* = (bp-q)/b
    bet_size      = Column(Numeric(10, 2), nullable=True)      # recommended stake (bankroll * kelly)
    bet_type      = Column(String(20), nullable=False)          # "win"|"place"
    detected_at   = Column(DateTime, nullable=False)
    __table_args__ = (UniqueConstraint("race_id", "horse_id", "bet_type"),)
```
**Validation:** `model_prob` ∈ (0, 1). `market_prob` = 1/odds. `edge` = model_prob - market_prob. Overlay when `edge > 0`. `kelly_fraction` ∈ [0, 1] (cap at 1; negative → 0, no bet).

#### 3.2.8 New Table: portfolio_recommendations
```python
class PortfolioRecommendation(Base):
    __tablename__ = "portfolio_recommendations"
    id            = Column(String(50), primary_key=True)       # UUID
    race_id       = Column(Integer, ForeignKey("races.id"), nullable=True)   # null for multi-race
    meeting_id    = Column(String(255), nullable=True)          # for quaddie/pick4
    budget        = Column(Numeric(10, 2), nullable=False)     # total stake
    risk_profile  = Column(String(20), nullable=False)          # "conservative"|"balanced"|"aggressive"
    tickets       = Column(JSONB, nullable=False)              # [{type, selections, stake, prob, payout, ev}]
    expected_value = Column(Numeric(10, 2), nullable=False)    # portfolio EV
    confidence    = Column(Numeric(5, 2), nullable=False)       # 0-100
    created_at    = Column(DateTime, nullable=False)
```
**Validation:** Sum of ticket stakes <= budget. `risk_profile` enum. Each ticket `type` ∈ {"win","place","quinella","exacta","trifecta","first4","quaddie","pick4"}. `expected_value` = Σ(ticket.stake * ticket.ev_per_dollar).

### 3.3 tipsharks-client — TypeScript Types

#### 3.3.1 New Types
```typescript
// frontend/src/types/index.ts additions

interface SimulationResult {
  raceId: string;
  runCount: number;
  winDistribution: Record<string, number>;      // horseId -> win count
  placeDistribution: Record<string, Record<string, number>>; // horseId -> {1: n, 2: n, 3: n}
  exoticDistribution: {
    exacta: Record<string, number>;               // "h1-h2" -> count
    trifecta: Record<string, number>;             // "h1-h2-h3" -> count
    quinella: Record<string, number>;
    first4: Record<string, number>;
  };
  confidenceIntervals: Record<string, ConfidenceInterval>;
  dnfRate?: Record<string, number>;              // harness only
  computedAt: string;
}

interface ConfidenceInterval {
  lower: number;      // 5th percentile
  median: number;     // 50th percentile
  upper: number;      // 95th percentile
  percentiles: { p5: number; p25: number; p50: number; p75: number; p95: number };
}

interface PortfolioTicket {
  id: string;
  raceId: string;
  budget: number;
  riskProfile: 'conservative' | 'balanced' | 'aggressive';
  tickets: Ticket[];
  expectedValue: number;
  confidence: number;
  createdAt: string;
}

interface Ticket {
  type: 'win' | 'place' | 'quinella' | 'exacta' | 'trifecta' | 'first4' | 'quaddie' | 'pick4';
  selections: string[];      // horse IDs or combinations
  stake: number;
  probability: number;
  payout: number;
  ev: number;                // expected value per dollar
}

interface ValueAlert {
  id: string;
  raceId: string;
  runnerId: string;
  runnerName: string;
  modelProbability: number;
  marketOdds: number;
  edge: number;
  kellyFraction: number;
  message: string;
  detectedAt: string;
}

interface SpeedMapData {
  raceId: string;
  trackConfig: {
    circumference: number;
    homeStraight: number;
    direction: 'Left' | 'Right';
  };
  runners: {
    id: string;
    name: string;
    barrier: number;
    settlingLine: number;       // expected position (1=lead)
    earlyPace: number;
    latePace: number;
  }[];
}

interface FeatureSummary {
  speedFigure: number;
  earlyPace: number;
  latePace: number;
  trackBias: number;
  weightAdj: number;
}
```

**Validation:** All types exported from `src/types/index.ts`. `ConfidenceInterval.lower <= median <= upper`. `Ticket.stake > 0`. `ValueAlert.edge > 0` (only overlays alert).

---

## 4. Algorithm Specifications

### 4.1 Speed Figure Calculation

**Input:** Raw race time, distance, track bias profile, moisture reading, wind speed/direction, weight carried.

**Formula:**
```
raw_speed = distance_m / raw_time_s                    # m/s
track_avg_speed = historical_avg_speed(venue, distance_bucket)  # from past races
bias_adj = track_bias_profile.bias_value              # from track_bias_profiles table
moisture_adj = f(moisture_reading)                    # linear: -0.5 per unit above baseline
wind_adj = f(wind_speed, wind_direction, horse_style) # headwind penalty for front-runners
weight_adj = (weight - benchmark_weight) * 0.1        # kg adjustment, 1kg ≈ 0.1 figure

speed_figure = 100 + ((raw_speed - track_avg_speed) / track_avg_speed) * 50
             + bias_adj + moisture_adj + wind_adj + weight_adj
```

**Validation:**
- `speed_figure` ∈ [0, 200]
- Reproducible: same inputs → same output
- Benchmark weight: 58kg thoroughbred, driver weight for harness
- `f(moisture)` = `-0.5 * max(0, moisture - baseline_moisture)` where baseline = track-specific
- `f(wind)` = `-wind_speed * cos(wind_angle - home_straight_angle) * 0.02` for front-runners; 0 for closers

### 4.2 Pace Rating Calculation

**Early Pace (Gate Speed):**
```
early_pace = 100 + (settling_line_inverse * 10) + (historical_gate_speed * 0.5)
# settling_line_inverse = (field_size - settling_line + 1) / field_size
# horses that settle closer to lead get higher early_pace
```

**Late Pace (Closing Burst):**
```
late_pace = 100 + ((closing_600m_time - field_avg_closing_600m) / field_avg_closing_600m) * -50
# faster closing time → higher late_pace
```

**Validation:** Both ∈ [0, 200]. `settling_line` from `speedmap.settling_lengths` (Runner.speedmapSettling). If no sectional data, `late_pace` = Elo-derived estimate.

### 4.3 Track Bias Normalization

**Input:** Venue, start_type, distance_bucket, historical race results.

**Algorithm:**
```
for each historical race at (venue, start_type, distance_bucket):
    front_runner_finish = avg finish position of horses with settling_line <= 3
    closer_finish = avg finish position of horses with settling_line > 3
    race_bias = closer_finish - front_runner_finish  # positive = front-runner bias

bias_value = mean(race_bias over last 50 races) * scaling_factor
```

**Validation:** `bias_value` ∈ [-10, 10]. Positive = favors front-runners. Negative = favors closers. Updated incrementally as new results arrive.

### 4.4 Weight Normalization (Thoroughbred)

**Input:** Weight carried, previous outing weight, benchmark weight.

**Formula:**
```
weight_delta = current_weight - previous_weight
weight_adj = weight_delta * 0.1    # 1kg ≈ 0.1 speed figure points
# horse dropping 3.5kg gets +0.35 figure upgrade

# relative to benchmark:
benchmark_adj = (current_weight - 58) * 0.1   # 58kg benchmark
```

**Validation:** Applied to thoroughbred only (category='T'). Harness uses driver weight, different formula. `weight_adj` ∈ [-5, 5].

### 4.5 Feature Vector Construction

**Required features (v1):**
| Feature | Source | Type |
|---------|--------|------|
| `elo_rating` | rating_snapshots | float |
| `speed_figure` | speed_figures | float |
| `early_pace` | pace_ratings | float |
| `late_pace` | pace_ratings | float |
| `barrier` | starters.barrier | int |
| `weight` | starters.handicap_m | float |
| `track_bias` | track_bias_profiles | float |
| `moisture` | WeatherSnapshot.moistureReading | float |
| `wind_speed` | WeatherSnapshot.windSpeed | float |
| `jockey_elo` | rating_snapshots (driver) | float |
| `trainer_elo` | rating_snapshots (trainer) | float |
| `days_since_last_run` | computed from last race date | int |
| `form_score` | last 8 starts positions normalized | float |
| `settling_line` | speedmapSettling | int |
| `gear_change` | gear vs previous gear | bool |

**Validation:** All features numeric (bool→0/1). Missing features → imputed (median from training set). Feature vector versioned (`version` field). Schema documented in `packages/features/schema.py`.

### 4.6 ML Ranking Model

**Model:** XGBoost/LightGBM ranking model (objective=`rank:pairwise` or `lambdarank`).

**Input:** Feature vector per (horse, race) pair.

**Output:** Predicted finishing order (rank scores, higher = better).

**Training:**
```
dataset = historical races (3-5 years)
X = feature_vectors
y = finishing positions (1=winner)
groups = race IDs (group by race for ranking)
model.fit(X, y, group=groups)
```

**Prediction:**
```
rank_scores = model.predict(feature_vectors_for_race)
predicted_order = argsort(rank_scores, descending)
```

**Validation:**
- Accuracy metric: top-1 hit rate, top-3 hit rate, Kendall's tau vs actual
- Model versioned in `ml_models` table
- Exactly one active model per target
- Training reproducible (seed, hyperparams stored)
- Accuracy published in `ml_models.accuracy`

### 4.7 Monte Carlo Simulation

**Input:** ML rank scores, feature vectors, race context (track, weather, harness flag).

**Algorithm:**
```python
def simulate_race(race, feature_vectors, ml_scores, seed, n_runs=10000):
    rng = np.random.default_rng(seed)
    results = []
    for _ in range(n_runs):
        # 1. Sample starting positions (barrier-dependent noise)
        start_positions = sample_starts(race, feature_vectors, rng)

        # 2. Sample in-running positions (checking/blocking)
        running_positions = sample_running(start_positions, rng)

        # 3. Sample DNF (harness only)
        if race.category == 'H':
            dnfs = sample_break_stride(race, feature_vectors, rng)
        else:
            dnfs = set()

        # 4. Sample finishing order around ML scores
        noise = rng.normal(0, pace_variance, size=n_horses)
        finish_scores = ml_scores + noise
        finish_scores[dnfs] = -inf
        finish_order = argsort(finish_scores, descending)

        # 5. Record outcome
        results.append({
            'win': finish_order[0],
            'place': finish_order[:3],
            'exacta': tuple(finish_order[:2]),
            'trifecta': tuple(finish_order[:3]),
            'first4': tuple(finish_order[:4]),
        })
    return aggregate(results)
```

**Samplers:**
- `sample_starts`: barrier-dependent Gaussian noise (wide barriers → higher variance)
- `sample_running`: position-dependent blocking probability (horses trapped wide → position penalty)
- `sample_break_stride`: per-horse DNF probability from HRNZ stewards' historical break-stride rate; Bernoulli sample
- `pace_variance`: `std = 0.1 * mean(ml_scores)` (calibrated noise)

**Output:** Win/place/exotic distributions, 90% CI (5th–95th percentile), percentiles.

**Validation:**
- Deterministic: same seed → identical output
- `run_count` == 10000
- Sum of win_dist == run_count
- `ci_lower[h] <= percentiles[h]['p50'] <= ci_upper[h]`
- DNF rate for harness matches historical break-stride rate ± 5%
- Performance: 10,000 runs < 5 seconds per race (single-threaded)

### 4.8 Value & Odds Rules

**Input:** Simulation win/place probabilities, market odds.

**Algorithm:**
```python
def find_value_bets(race, simulation, odds):
    value_bets = []
    for horse in race.starters:
        model_prob = simulation.win_dist[horse.id] / simulation.run_count
        market_odds = odds[horse.id]
        market_prob = 1 / market_odds
        edge = model_prob - market_prob

        if edge > 0:  # overlay
            b = market_odds - 1
            p = model_prob
            q = 1 - p
            kelly = (b * p - q) / b
            kelly = max(0, min(1, kelly))  # cap at [0, 1]
            bet_size = bankroll * kelly

            value_bets.append(ValueBet(
                horse_id=horse.id,
                model_prob=model_prob,
                market_prob=market_prob,
                market_odds=market_odds,
                edge=edge,
                kelly_fraction=kelly,
                bet_size=bet_size,
                bet_type='win',
            ))
    return value_bets
```

**Validation:**
- `market_prob` = 1 / `market_odds`
- `edge` = `model_prob` - `market_prob`
- Only overlays (`edge > 0`) stored as value bets
- `kelly_fraction` ∈ [0, 1]
- `bet_size` = `bankroll * kelly_fraction` (bankroll from user config, default $1000)

### 4.9 Portfolio Optimizer

**Input:** Simulation exotic distributions, budget, risk profile.

**Algorithm:**
```python
def optimize_portfolio(race, simulation, budget, risk_profile):
    # 1. Generate candidate tickets based on risk profile
    if risk_profile == 'conservative':
        candidates = generate_win_place_tickets(simulation)
    elif risk_profile == 'balanced':
        candidates = generate_quinella_exacta_tickets(simulation)
    elif risk_profile == 'aggressive':
        candidates = generate_trifecta_first4_tickets(simulation)

    # 2. Score each ticket by EV
    for ticket in candidates:
        ticket.prob = simulation.exotic_dist[ticket.type][ticket.key] / simulation.run_count
        ticket.payout = ticket.stake_unit * odds_lookup(ticket)
        ticket.ev = (ticket.prob * ticket.payout) - ticket.stake_unit

    # 3. Budget-constrained Kelly allocation
    # Maximize Σ(ticket.ev * allocation) s.t. Σ(allocation * stake_unit) <= budget
    allocation = kelly_portfolio(candidates, budget)

    # 4. Build portfolio
    portfolio = PortfolioRecommendation(
        tickets=[t for t in candidates if allocation[t] > 0],
        expected_value=sum(t.ev * allocation[t] for t in candidates),
        confidence=compute_confidence(simulation, portfolio),
    )
    return portfolio
```

**Validation:**
- Sum of ticket stakes <= budget
- Each ticket `ev` = (prob * payout) - stake
- `expected_value` = Σ(ticket.ev * allocation)
- `confidence` ∈ [0, 100] (derived from simulation run count and distribution sharpness)
- Risk profile constrains ticket types:
  - conservative: win, place only
  - balanced: + quinella, exacta
  - aggressive: + trifecta, first4, quaddie, pick4
- Optimizer prunes candidates with `ev < 0` (negative EV tickets excluded)

---

## 5. API Contracts

### 5.1 tipsharks-elo-api — New Endpoints

All under `/v1/` prefix. JSON responses. Bearer token for admin endpoints.

#### 5.1.1 GET /v1/races/{id}/simulation
**Request:** path param `id` (int race ID). Query `?refresh=false` (use cache).
**Response 200:**
```json
{
  "race_id": 12345,
  "run_count": 10000,
  "seed": 42,
  "win_distribution": {"101": 3500, "102": 2800, ...},
  "place_distribution": {"101": {"1": 3500, "2": 2000, "3": 1500}, ...},
  "exotic_distribution": {
    "exacta": {"101-102": 1200, ...},
    "trifecta": {"101-102-103": 500, ...},
    "quinella": {"101-102": 1800, ...},
    "first4": {"101-102-103-104": 200, ...}
  },
  "confidence_intervals": {
    "101": {"lower": 0.28, "median": 0.35, "upper": 0.42, "percentiles": {"p5": 0.28, "p25": 0.32, "p50": 0.35, "p75": 0.38, "p95": 0.42}}
  },
  "dnf_rate": {"101": 50, "102": 120},
  "computed_at": "2026-06-22T10:30:00Z"
}
```
**Response 404:** race not found. **Response 202:** simulation in progress (if not cached).
**Validation:** `run_count` == sum of `win_distribution` values. `lower <= median <= upper` for all horses.

#### 5.1.2 POST /v1/admin/simulate
**Request:** Bearer token. Body:
```json
{"race_id": 12345, "run_count": 10000, "seed": 42, "force": false}
```
**Response 202:** simulation queued. **Response 200:** simulation complete (returns result).
**Validation:** `run_count` ∈ [1000, 100000]. `seed` optional (default: random).

#### 5.1.3 GET /v1/races/{id}/value-bets
**Response 200:**
```json
{
  "race_id": 12345,
  "value_bets": [
    {
      "horse_id": 101,
      "horse_name": "Silver Comet",
      "model_prob": 0.35,
      "market_prob": 0.10,
      "market_odds": 10.0,
      "edge": 0.25,
      "kelly_fraction": 0.28,
      "bet_size": 280.00,
      "bet_type": "win",
      "detected_at": "2026-06-22T10:35:00Z"
    }
  ]
}
```
**Validation:** All `edge > 0`. `kelly_fraction` ∈ [0, 1].

#### 5.1.4 GET /v1/value-alerts
**Query:** `?since=2026-06-22T00:00:00Z&limit=50`
**Response 200:**
```json
{
  "alerts": [
    {
      "id": "alert-uuid",
      "race_id": 12345,
      "horse_id": 101,
      "horse_name": "Silver Comet",
      "model_probability": 0.35,
      "market_odds": 10.0,
      "edge": 0.25,
      "message": "Model detects 35% win probability; Market price offers 10-1",
      "detected_at": "2026-06-22T10:35:00Z"
    }
  ],
  "total": 1
}
```

#### 5.1.5 POST /v1/optimize/portfolio
**Request:**
```json
{
  "race_id": 12345,
  "budget": 50.00,
  "risk_profile": "balanced",
  "bankroll": 1000.00
}
```
**Response 200:**
```json
{
  "id": "portfolio-uuid",
  "race_id": 12345,
  "budget": 50.00,
  "risk_profile": "balanced",
  "tickets": [
    {
      "type": "exacta",
      "selections": ["101", "102"],
      "stake": 25.00,
      "probability": 0.12,
      "payout": 208.33,
      "ev": 0.00
    },
    {
      "type": "quinella",
      "selections": ["101", "103"],
      "stake": 25.00,
      "probability": 0.18,
      "payout": 138.89,
      "ev": 0.00
    }
  ],
  "expected_value": 12.50,
  "confidence": 78.5,
  "created_at": "2026-06-22T10:40:00Z"
}
```
**Validation:** Sum of `stake` <= `budget`. `risk_profile` ∈ {"conservative","balanced","aggressive"}. Ticket types match risk profile constraints.

#### 5.1.6 GET /v1/portfolios/{id}
**Response 200:** same as POST response. **Response 404:** not found.

#### 5.1.7 GET /v1/races/{id}/features
**Response 200:**
```json
{
  "race_id": 12345,
  "feature_vectors": [
    {
      "horse_id": 101,
      "version": "v1",
      "features": {
        "elo_rating": 1850.0,
        "speed_figure": 112.5,
        "early_pace": 95.0,
        "late_pace": 108.0,
        "barrier": 4,
        "weight": 58.0,
        "track_bias": 2.5,
        "moisture": 3.2,
        "wind_speed": 12.0,
        "jockey_elo": 1750.0,
        "trainer_elo": 1680.0,
        "days_since_last_run": 14,
        "form_score": 0.72,
        "settling_line": 3,
        "gear_change": false
      },
      "computed_at": "2026-06-22T10:25:00Z"
    }
  ]
}
```

#### 5.1.8 GET /v1/races/{id}/speed-map
**Response 200:**
```json
{
  "race_id": 12345,
  "track_config": {
    "circumference": 1600,
    "home_straight": 350,
    "direction": "Left"
  },
  "runners": [
    {
      "id": 101,
      "name": "Silver Comet",
      "barrier": 4,
      "settling_line": 3,
      "early_pace": 95.0,
      "late_pace": 108.0
    }
  ]
}
```

#### 5.1.9 WebSocket /ws/races/{race_id}/simulation
**Messages (server → client):**
```json
{"type": "progress", "runs_complete": 5000, "run_count": 10000, "percent": 50.0}
{"type": "complete", "result": {<same as GET /simulation>}}
{"type": "error", "message": "Simulation failed: <reason>"}
```

### 5.2 tipsharks-client backend — New Endpoints

All under `/api/` prefix. Proxy to elo-api.

#### 5.2.1 GET /api/simulation/{race_id}
**Response:** proxied from elo-api `GET /v1/races/{id}/simulation`. Cached in MongoDB (TTL 10min).

#### 5.2.2 POST /api/optimize/portfolio
**Request:**
```json
{"race_id": "12345", "budget": 50.00, "risk_profile": "balanced"}
```
**Response:** proxied from elo-api `POST /v1/optimize/portfolio`. Cached in MongoDB (TTL 5min).

#### 5.2.3 GET /api/value-alerts
**Query:** `?since=ISO8601&limit=50`
**Response:** proxied from elo-api `GET /v1/value-alerts`.

#### 5.2.4 GET /api/portfolios/{id}
**Response:** proxied from elo-api `GET /v1/portfolios/{id}`.

#### 5.2.5 GET /api/races/{id}/speed-map
**Response:** proxied from elo-api `GET /v1/races/{id}/speed-map`.

#### 5.2.6 POST /api/notifications/value-alert
**Request:**
```json
{"race_id": "12345", "channels": ["push", "sms"]}
```
**Response 202:** notification scheduled. Uses existing Twilio/SendGrid/Resend integration.

---

## 6. Interface Contracts (UI)

### 6.1 SpeedMap Component

**File:** `frontend/src/components/SpeedMap.tsx`

**Props:**
```typescript
interface SpeedMapProps {
  raceId: string;
  data: SpeedMapData;
  onReposition: (runnerId: string, newSettlingLine: number) => void;
  onPaceLeaderMiss: (runnerId: string, missed: boolean) => void;
  interactive: boolean;
}
```

**Behavior:**
- Render track as SVG oval (circumference, home straight, direction)
- Render runners as draggable tokens positioned by `settlingLine`
- Drag runner to new position → `onReposition` callback → triggers re-simulation
- Toggle "pace leader misses start" → `onPaceLeaderMiss` callback → re-simulation
- Color tokens by early_pace (red=high, blue=low)
- Show late_pace as arrow length from token

**Validation:**
- Drag-drop works with React Native Gesture Handler + Reanimated
- Re-simulation debounced (500ms after drag end)
- Tokens never overlap (auto-spacing)
- Accessible: VoiceOver labels for runner positions

### 6.2 ConfidenceCurve Component

**File:** `frontend/src/components/ConfidenceCurve.tsx`

**Props:**
```typescript
interface ConfidenceCurveProps {
  horseId: string;
  ci: ConfidenceInterval;
  trackConditionShift?: 'Good' | 'Soft' | 'Heavy';
  showPercentiles: boolean;
}
```

**Behavior:**
- Render distribution curve (SVG path) from percentiles
- Shade 90% CI region (5th–95th percentile)
- Mark median (50th percentile) with vertical line
- If `trackConditionShift` provided, show shifted distribution (dashed line)
- Toggle percentiles display (p25, p75 markers)

**Validation:**
- Curve monotonic (CDF shape)
- `lower <= median <= upper` visually
- Shifted distribution shows downside when Good→Heavy
- Color: green for high median, red for high variance

### 6.3 TicketBuilder Component

**File:** `frontend/src/components/TicketBuilder.tsx`

**Props:**
```typescript
interface TicketBuilderProps {
  raceId: string;
  onOptimize: (budget: number, riskProfile: RiskProfile) => void;
  result?: PortfolioTicket;
  isLoading: boolean;
}
```

**Behavior:**
- Budget input (numeric, $1–$1000)
- Risk profile selector (3 buttons: Conservative, Balanced, Aggressive)
- "Optimize" button → `onOptimize` callback
- Result display: ticket list (type, selections, stake, prob, payout, EV)
- Portfolio summary: total stake, expected value, confidence
- Save portfolio button

**Validation:**
- Budget input validated (1–1000)
- Risk profile defaults to "balanced"
- Result tickets sorted by EV descending
- Sum of stakes displayed, must <= budget

### 6.4 ValueAlerts Component

**File:** `frontend/src/components/ValueAlerts.tsx`

**Props:**
```typescript
interface ValueAlertsProps {
  alerts: ValueAlert[];
  onDismiss: (alertId: string) => void;
  onSubscribe: (raceId: string, channels: NotificationChannel[]) => void;
}
```

**Behavior:**
- List of alert cards: runner name, model prob vs market odds, edge, message
- Dismiss button per alert
- Subscribe to notifications for race
- Pull-to-refresh

**Validation:**
- Alerts sorted by `edge` descending
- Only `edge > 0` alerts shown
- Dismissed alerts removed from list

### 6.5 Enhanced Race Detail Screen

**File:** `app/race/[id].tsx` (modified)

**New sections:**
1. Speed Map (interactive, collapsible)
2. Confidence Curves (per runner, expandable)
3. Value Bets panel (if any overlays)
4. Ticket Builder (CTA)
5. Enhanced runner cards (CI ranges instead of static bars)

**Validation:**
- Speed map loads from `/api/races/{id}/speed-map`
- Confidence curves load from `/api/simulation/{race_id}`
- Value bets panel shows only when overlays exist
- Ticket builder CTA navigates to `app/ticket-builder.tsx`

### 6.6 New Screens

#### ticket-builder.tsx
- Full-screen TicketBuilder component
- Race context header
- Save portfolio to MongoDB

#### value-alerts.tsx
- Full-screen ValueAlerts component
- Filter by race/date
- Notification subscription management

---

## 7. Integration Contracts

### 7.1 tab-api-ingest → elo-api

**Existing:** `packages/ingest_client/client.py` HTTP client.

**New data sync:** elo-api pulls new fields from tab-api-ingest Prisma DB via:
- Option A: Direct DB read (shared PostgreSQL) — preferred for performance
- Option B: New REST endpoints on tab-api-ingest exposing new fields

**Contract:** elo-api `ingest_client` extended to fetch:
- Race track context fields (trackDirection, railPosition, etc.)
- Runner new fields (gear, speedmapSettling, flucs, etc.)
- Result sectionals (mileRate400, mileRate800, sectionalTimes)
- WeatherSnapshot records

**Validation:** Sync idempotent. New fields nullable (backward compatible). Sync triggered by existing admin/ingest endpoint.

### 7.2 External Weather API → tab-api-ingest

**Source:** Bureau of Meteorology (BOM) or OpenWeatherMap.

**Integration:** New BullMQ queue `weather-fetch`:
- Triggered by morning-scrape worker (after meetings fetched)
- Per meeting venue: fetch weather forecast + current conditions
- Store in WeatherSnapshot table

**Contract:**
```
GET https://api.openweathermap.org/data/2.5/forecast?lat={lat}&lon={lon}&appid={key}
→ parse temperature, wind_speed, wind_deg, rain (moisture proxy)
→ store WeatherSnapshot per race (interpolated by race start time)
```

**Validation:** One WeatherSnapshot per race per source per capture. Temperature in Celsius. Wind in km/h. Moisture from rain volume (mm) as proxy if penetrometer unavailable.

### 7.3 HRNZ Stewards → elo-api

**Existing:** `packages/hrnz_scraper/` package.

**New:** Scrape stewards' reports for break-stride incidents.
- Parse stewards' report text for "broke stride", "galloped", "failed to settle"
- Aggregate per-horse break-stride rate over rolling 20-race window
- Store in new table `break_stride_rates` (horse_id, rate, sample_count, updated_at)

**Validation:** Rate ∈ [0, 1]. Sample_count >= 5 for reliable estimate. Used by Monte Carlo `sample_break_stride`.

### 7.4 elo-api → tipsharks-client

**Existing:** `EloApiClient` in `backend/server.py`.

**New endpoints proxied:** See §5.2.

**Contract:** client backend proxies all new elo-api endpoints with MongoDB caching:
- Simulation results: TTL 10min
- Portfolio recommendations: TTL 5min
- Value alerts: TTL 1min
- Speed map: TTL 30min

**Validation:** Proxy preserves response shape. Cache invalidation on force-refresh. Fallback to mock if elo-api unavailable (existing pattern).

---

## 8. Non-Functional Requirements

### 8.1 Performance
| Operation | Target | Max |
|-----------|--------|-----|
| Monte Carlo simulation (10k runs, 12-horse field) | < 3s | 5s |
| Portfolio optimization (balanced, 50-horse field) | < 2s | 5s |
| Feature vector computation (per race) | < 500ms | 1s |
| ML prediction (per race) | < 100ms | 200ms |
| Speed map API response | < 200ms | 500ms |
| Value bet scan (per race) | < 100ms | 200ms |
| Mobile speed map render | < 1s | 2s |
| Mobile confidence curve render | < 500ms | 1s |

### 8.2 Scalability
- Simulation worker: concurrent races via Python RQ/Celery (max 5 parallel)
- Portfolio optimizer: request-scoped, no shared state
- Mobile cache: AsyncStorage + MongoDB TTL

### 8.3 Reliability
- Simulation deterministic (seeded) — reproducible for debugging
- Feature engineering degrades gracefully (missing features → imputed)
- ML model fallback: if ML unavailable, use Elo-only predictions
- External weather API failure → null WeatherSnapshot, sim proceeds without weather adj

### 8.4 Security
- Admin endpoints (simulate, optimize) require Bearer token (existing)
- No secrets in API responses
- Weather API key in env var, never logged
- Portfolio recommendations don't persist bankroll (client-side only)

### 8.5 Observability
- Simulation runs logged with seed, duration, race_id
- ML model accuracy tracked in `ml_models.accuracy`
- Value bet detection rate monitored
- Portfolio optimizer EV tracked over time

---

## 9. Validation Criteria

### 9.1 Data Validation
- [ ] All new Prisma fields nullable (backward compatible)
- [ ] Prisma migration up/down tested
- [ ] SQLAlchemy migrations up/down tested
- [ ] WeatherSnapshot: one per race per source per capture
- [ ] Feature vectors: all required keys present (v1 schema)
- [ ] Simulation results: `run_count` == sum of `win_distribution`
- [ ] Value bets: all `edge > 0`, `kelly_fraction` ∈ [0, 1]
- [ ] Portfolio: sum of stakes <= budget

### 9.2 Algorithm Validation
- [ ] Speed figure: same inputs → same output (deterministic)
- [ ] Speed figure: ∈ [0, 200]
- [ ] Pace ratings: ∈ [0, 200]
- [ ] Track bias: ∈ [-10, 10]
- [ ] Weight norm: 3.5kg drop → +0.35 figure (unit test)
- [ ] ML model: top-1 hit rate > 30% on validation set (baseline)
- [ ] ML model: top-3 hit rate > 60% on validation set
- [ ] Monte Carlo: same seed → identical output (100 tests)
- [ ] Monte Carlo: 10,000 runs < 5s per race
- [ ] Monte Carlo: DNF rate matches historical ± 5% (harness)
- [ ] Kelly: `f* = (bp - q) / b` formula verified
- [ ] Portfolio: no negative EV tickets included
- [ ] Portfolio: risk profile constrains ticket types

### 9.3 API Validation
- [ ] All new endpoints return correct status codes (200/202/404)
- [ ] Response shapes match §5 contracts
- [ ] Bearer token required for admin endpoints
- [ ] WebSocket simulation progress messages correct
- [ ] Client backend proxies preserve response shape
- [ ] MongoDB cache TTLs enforced

### 9.4 UI Validation
- [ ] SpeedMap: drag-drop repositions runner
- [ ] SpeedMap: re-simulation triggered on drag end (debounced)
- [ ] SpeedMap: pace leader miss toggle works
- [ ] ConfidenceCurve: distribution curve renders
- [ ] ConfidenceCurve: 90% CI shaded
- [ ] ConfidenceCurve: track condition shift shows dashed line
- [ ] TicketBuilder: budget input validated (1–1000)
- [ ] TicketBuilder: risk profile selector works
- [ ] TicketBuilder: result tickets sorted by EV
- [ ] TicketBuilder: sum of stakes <= budget
- [ ] ValueAlerts: sorted by edge descending
- [ ] ValueAlerts: only edge > 0 shown
- [ ] ValueAlerts: dismiss removes from list
- [ ] Enhanced race detail: all new sections render
- [ ] Mobile render performance within targets (§8.1)

### 9.5 Integration Validation
- [ ] tab-api-ingest → elo-api: new fields sync idempotent
- [ ] Weather API: WeatherSnapshot stored per race
- [ ] HRNZ stewards: break-stride rates aggregated
- [ ] elo-api → client: all new endpoints proxied
- [ ] Cache invalidation on force-refresh

### 9.6 Regression Validation
- [ ] Existing Elo engine unchanged (feature-flagged augmentation)
- [ ] Existing API endpoints backward compatible
- [ ] Existing mobile screens functional
- [ ] Existing tests pass (23 elo-api, 10 tab-api-ingest, 8 client)
- [ ] No performance regression on existing endpoints

---

## 10. Test Specifications

### 10.1 tab-api-ingest Tests

| Test | File | Validates |
|------|------|-----------|
| New field parsing | `src/api/tab/__tests__/tab-api-client.unit.test.ts` | New fields parsed from API response |
| New field persistence | `src/services/__tests__/race-service.unit.test.ts` | New fields stored in Prisma |
| WeatherSnapshot ingest | `src/services/__tests__/weather-service.test.ts` | Weather fetched + stored |
| Migration up/down | `prisma/__tests__/migration.test.ts` | Schema reversible |

### 10.2 tipsharks-elo-api Tests

| Test | File | Validates |
|------|------|-----------|
| Speed figure | `tests/test_speed_figures.py` | Determinism, range, weight adj |
| Pace ratings | `tests/test_pace_ratings.py` | Range, settling_line mapping |
| Track bias | `tests/test_track_bias.py` | Range, incremental update |
| Weight normalization | `tests/test_weight_norm.py` | 3.5kg drop → +0.35 |
| Feature vectors | `tests/test_feature_vectors.py` | All required keys, imputation |
| ML model training | `tests/test_ml_training.py` | Reproducibility, accuracy thresholds |
| ML prediction | `tests/test_ml_prediction.py` | Output shape, ranking correctness |
| Monte Carlo determinism | `tests/test_monte_carlo.py` | Same seed → same output (100 cases) |
| Monte Carlo performance | `tests/test_monte_carlo_perf.py` | 10k runs < 5s |
| Monte Carlo DNF | `tests/test_monte_carlo_dnf.py` | Harness break-stride rate match |
| Value bets | `tests/test_value_bets.py` | Kelly formula, edge > 0, kelly ∈ [0,1] |
| Portfolio optimizer | `tests/test_portfolio_optimizer.py` | Budget constraint, risk profile, EV |
| New API endpoints | `tests/test_api_endpoints.py` (extend) | All new endpoints, status codes, shapes |
| WebSocket simulation | `tests/test_websocket.py` (extend) | Progress + complete messages |
| Integration | `tests/test_integration.py` (extend) | End-to-end: features → ML → sim → value → portfolio |

### 10.3 tipsharks-client Tests

| Test | File | Validates |
|------|------|-----------|
| SpeedMap | `src/components/__tests__/SpeedMap.test.tsx` | Drag-drop, reposition callback |
| ConfidenceCurve | `src/components/__tests__/ConfidenceCurve.test.tsx` | Curve render, CI shading |
| TicketBuilder | `src/components/__tests__/TicketBuilder.test.tsx` | Budget validation, risk profile, result |
| ValueAlerts | `src/components/__tests__/ValueAlerts.test.tsx` | Sort, dismiss, edge > 0 |
| ticket-builder screen | `app/__tests__/ticket-builder.test.tsx` | Full screen flow |
| value-alerts screen | `app/__tests__/value-alerts.test.tsx` | Full screen flow |
| Enhanced race detail | `app/__tests__/race-detail.test.tsx` (extend) | New sections render |
| Backend proxy | `tests/test_proxy_endpoints.py` | New endpoints proxied, cache TTL |

### 10.4 Test Coverage Targets
| Service | Current | Target |
|---------|---------|--------|
| tab-api-ingest | ~80 cases | +40 cases (new fields, weather) |
| tipsharks-elo-api | ~41% overall | 70% overall, 90% new packages |
| tipsharks-client | ~30 cases | +25 cases (new components/screens) |

---

## 11. Glossary

| Term | Definition |
|------|-----------|
| True Pace | Track-bias-corrected speed figure |
| Closing Burst | Late pace rating from closing sectional |
| Settling Line | Expected early running position (1=lead) |
| Overlay | Model prob > market-implied prob (value bet) |
| Underlay | Model prob < market-implied prob (trap) |
| Kelly Fraction | Optimal bet size fraction: `f* = (bp-q)/b` |
| Risk Profile | Conservative/Balanced/Aggressive ticket strategy |
| DNF Layer | Harness break-stride probability sampling |
| 90% CI | 5th–95th percentile from Monte Carlo |
| Feature Vector | Engineered inputs to ML model |
| Exotic Bet | Exacta/Trifecta/Quinella/First4/Quaddie/Pick4 |
| Penetrometer | Track moisture measuring device |
| HRNZ | Harness Racing New Zealand |
| TAB | Totalisator Agency Board (AUS/NZ betting operator) |
| BOM | Bureau of Meteorology (AUS weather source) |

---

## Appendix A: Racing Data Component Mapping (Reference)

| Racing Component | Data Source | Implementation |
|------------------|------------|----------------|
| Sectional timing (opening 400m, closing 600m) | TAB API mile_rate_400/800, Result.sectionalTimes | Speed figures (True Pace + closing burst) |
| Track condition (Good/Soft/Heavy, Fast/Slushy) | Race.trackCondition, WeatherSnapshot | Stamina drop-off model, moisture degradation |
| Barrier draw / post position + gate speed | Runner.barrier, Runner.speedmapSettling | Early positioning efficiency, trapped-wide risk |
| Trainer + stud/bloodline | Horse.sire/dam/damSire, Trainer Elo | Trainer strike rate at track/distance |
| Jockey (T) / Driver (H) analytics | Driver Elo, Runner.jockeyName | Tactical execution, pathfinding, track ROI |
| Moisture, temp, wind | WeatherSnapshot | Dynamic sim adjustment for headwind |
| Breaking stride (harness) | HRNZ stewards' reports | DNF probability sampling in Monte Carlo |
| Win/Place/Exotic dividends | Dividend table, Result.winDividend/placeDividend | Tote dividend rules conversion |
| Exotic bet portfolio | Simulation exotic distributions | Millions of permutations, Kelly-constrained |
| Risk profiles | User input | Conservative/Balanced/Aggressive |
| 90% CI for favorites | Simulation percentiles | Safe floor vs volatile ceiling |
| Risk tiers | Risk profile selection | Win/place → quinella/exacta → trifecta/first4 |

## Appendix B: Phase Dependencies

```
Phase 0 (Schema) ──> Phase 1 (Features) ──> Phase 2 (ML) ──> Phase 3 (Monte Carlo)
                                                              │
                                                              ├──> Phase 4 (Value) ──> Phase 5 (Optimizer)
                                                              │
                                                              └──> Phase 6 (UI) [can start after Phase 3]
                                                              │
                                                              └──> Phase 7 (Web UI) [after Phase 5]
```

- Phase 0 blocks all (data needed)
- Phase 1 blocks Phase 2 (features needed for ML)
- Phase 2 blocks Phase 3 (ML scores needed for sim)
- Phase 3 blocks Phase 4 (sim needed for value)
- Phase 4 blocks Phase 5 (value needed for portfolio)
- Phase 6 (UI) can start after Phase 3 (sim results available); integrates Phase 4/5 as they complete
- Phase 7 (Web UI) after Phase 5 (all backend features ready)