# Sardine — open data

Regenerated every 30 minutes. This copy: **2026-09-18T08:02:20Z**.

Licence: **CC BY 4.0**. Use it, publish it, check our arithmetic with it; credit *Sardine (sardine.info)*.


---

## The one thing to know before you count anything

**A row is not an observation.** One hourly BTC window quotes about 188 strikes off a single price path, and they all settle together: if BTC finishes high, every strike below it wins at once. So 511,616 rows describe **358 independent windows**, and treating rows as evidence overstates the sample by up to 93x.


Every sample size we publish is counted in **distinct settled windows** (`window_close_utc`). The row count is in the table below only so you can check the file downloaded completely.


## Files

| File | Rows | Windows | Covers |
|---|---|---|---|
| `sardine_history_btc_15m.csv` | 29,002 | 5,800 | 2026-07-19 .. 2026-09-18 |
| `sardine_history_btc_hourly.csv` | 578,856 | 505 | 2026-08-23 .. 2026-09-18 |
| `sardine_published.csv` | 4,657 | 1,258 | 2026-09-11 .. 2026-09-18 |
| `baserate_tables.json` | 313 | 5 | — |

## Columns — `sardine_history_*.csv`

One row per contract per checkpoint, for every window that has settled.

| Column | Meaning |
|---|---|
| `window_open_utc` | When the window opened. ISO 8601, UTC, always. |
| `window_close_utc` | When it settled. **This is the unit of evidence** — group by it before you count anything. |
| `ticker` | Kalshi's contract ticker, verbatim. |
| `checkpoint_seconds_left` | Seconds to close when this reading was taken: 120, 180, 300, 600, 900, 1200, 1800, 2700 or 3600. |
| `spot_at_checkpoint` | BTC at that moment, from the exchange's minute candle keyed to the time the candle CLOSED. Keying it to the candle's start would let a price up to 60s in the future leak into the past; that bug existed and was fixed, see Exclusions. |
| `strike` | The contract's target price. |
| `distance_usd` | `spot_at_checkpoint - strike`. |
| `distance_pct` | `(spot/strike - 1) * 100`. |
| `z` | Distance in standard deviations: `ln(spot/strike) / vol_scale`. This is the only thing the table is looked up by. |
| `vol_scale` | The divisor above: one-minute realised volatility over the trailing 60 minutes, times the square root of the remaining window. Published so `z` is reproducible from this file alone. |
| `sardine_p` | What our published table said for this cell — the number shown on the site. Blank where the cell had too little history to answer. |
| `settled_yes` | 1 if the contract settled YES, 0 if NO. |

## Columns — `sardine_published.csv`

Every number we actually put on screen, with Kalshi's price beside it.

| Column | Meaning |
|---|---|
| `shown_utc` | When this reading was displayed. |
| `ticker` | The contract. |
| `checkpoint_seconds_left` | Which checkpoint this reading is about. |
| `sardine_p` | What we showed. |
| `kalshi_yes_ask` | Kalshi's asking price for YES at that moment, 0–1. |
| `kalshi_yes_bid` | The bid. |
| `fee` | Kalshi's taker fee for this contract at that price: `ceil(0.07 * p * (1-p) * n * 100) / 100` per contract. |
| `verdict` | The internal state name behind the word on screen: *Priced below history* was shown as **Cheaper**, *About right* as **About usual**, *Priced above history* as **Pricier**. |
| `settled_yes` | How it finished, blank until it settles. |
| `options_implied_p` | What the options market implies for the SAME contract: N(d2) with zero drift, using Deribit's DVOL as the volatility and the same Asian-effective horizon our own number uses. Recorded for research; it is on no screen and we do not act on it. Blank where the index was unavailable. |
| `dvol` | The volatility used above: Deribit's 30-day at-the-money implied volatility index, annualised, in percent. Published beside the price so it can be re-derived rather than trusted. NOTE it is a 30-DAY horizon; Deribit lists no option shorter than ~12 hours, so there is no short end to interpolate from. |

### A gap in this file: 2026-09-14 15:29 to 2026-09-17 01:00 UTC

**There are no rows for those ~57 hours, and there never will be.** The log's only writer was an endpoint belonging to a page that the v3 redesign retired. Nothing called it after that, the log stopped, and nothing was watching the thing that records what we showed — so it went unnoticed until someone went looking for an unrelated reason.


Rows before the gap are real. Rows after it are real. Nothing in between was reconstructed or estimated: a number we did not record at the time is a number we cannot honestly claim to have shown. The writer now sits on the path that actually serves readers, and a watchdog condition (`published_stale`) fires if this file goes 30 minutes without a row while contracts are open.


**Dedup rule.** The site snapshots every minute, so one contract appears ~24 times at the same checkpoint. This file keeps, for each (contract, checkpoint), the single reading whose time-to-close is closest to that checkpoint. Nothing is averaged and nothing is dropped on the basis of what it said.


## `baserate_tables.json`

The lookup table as served, byte for byte. `assets` are the markets we publish; `held` are markets we built and did not publish, and why. Each cell is keyed by `(z bucket, checkpoint)` and carries `p` (the settled-YES rate), `n` (strike-rows) and `n_eff` (**distinct windows — the honest sample size**).


## Accuracy

The figure on [/how](https://sardine.info/how) is the **largest miss in any prediction bucket holding 200+ settled windows**, and it is computed by reading these files back — not from a database we hold privately.


**BTC-15M** — worst miss **+4.5 points** in the 50-60¢ bucket.

| Bucket | Windows | We said | It settled | Miss (pts) | Counted |
|---|---|---|---|---|---|
| 0-10¢ | 2,288 | 6.3% | 8.5% | -2.2 | yes |
| 10-20¢ | 722 | 12.7% | 15.0% | -2.3 | yes |
| 20-30¢ | 2,280 | 23.7% | 24.6% | -0.9 | yes |
| 30-40¢ | 69 | 30.9% | 52.2% | -21.3 | no — under the floor |
| 40-50¢ | 4,801 | 43.6% | 46.5% | -2.8 | yes |
| 50-60¢ | 1,897 | 58.3% | 53.8% | +4.5 | yes |
| 60-70¢ | 2,642 | 65.6% | 64.2% | +1.4 | yes |
| 70-80¢ | 1,002 | 77.9% | 75.1% | +2.7 | yes |
| 80-90¢ | 1,592 | 85.7% | 83.2% | +2.5 | yes |
| 90-100¢ | 2,000 | 96.5% | 94.2% | +2.4 | yes |

**BTC-1H** — worst miss **+6.5 points** in the 40-50¢ bucket.

| Bucket | Windows | We said | It settled | Miss (pts) | Counted |
|---|---|---|---|---|---|
| 0-10¢ | 497 | 3.7% | 1.0% | +2.7 | yes |
| 10-20¢ | 449 | 18.1% | 20.5% | -2.4 | yes |
| 20-30¢ | 471 | 21.7% | 22.3% | -0.6 | yes |
| 30-40¢ | 482 | 38.9% | 42.5% | -3.7 | yes |
| 40-50¢ | 406 | 41.8% | 48.3% | -6.5 | yes |
| 60-70¢ | 484 | 61.3% | 65.7% | -4.4 | yes |
| 70-80¢ | 383 | 79.9% | 80.2% | -0.3 | yes |
| 80-90¢ | 483 | 81.9% | 86.8% | -4.8 | yes |
| 90-100¢ | 497 | 97.1% | 98.6% | -1.5 | yes |

**A positive miss means we said more than happened.**


This is a *description*, not a forecast test: it scores the published table over every window we hold, including the windows the table was built from. The stricter out-of-sample test is the split-half gate below, and it is what decides whether a market is served at all.


## Split-half gate

Fit the table on one calendar half, score it on the other, both directions, take the worst. A market is served only if that worst error is inside **6 points**.


| Market | Served | Split-half error | Windows | Span | Note |
|---|---|---|---|---|---|
| BTC-15M | yes | **6.6 pts** (re-measured 2026-09-17; table still claims 3.8) | 10497 | 2026-05-22 .. 2026-09-09 |  on probation — words already descriptive |
| BTC-1H | yes | **7.2 pts** (re-measured 2026-09-14; table still claims 4.9) | 320 | 2026-08-23 .. 2026-09-09 |  on probation — words already descriptive |
| ETH-15M | yes | 4.5 pts | 10490 | 2026-05-22 .. 2026-09-09 |  |
| ETH-1H | no | 12.2 pts | — | 2026-08-29 .. 2026-09-09 | calibration error 0.122 exceeds the 0.06 limit |
| XRP-15M | no | 7.3 pts | — | 2026-05-22 .. 2026-09-09 | calibration error 0.073 exceeds the 0.06 limit |

## Exclusions — what is NOT in these files, and why

- **Unsettled windows.** A row appears only once Kalshi has settled it.

- **Rows we could not price.** If the exchange's candle feed had a gap at a checkpoint, there is no spot and no volatility, so no row. These are absent, not zero-filled.

- **Everything before the look-ahead fix.** Spot was once keyed to the candle's START while carrying its CLOSE, so a price up to 60 seconds in the future leaked into the past — worth about half the remaining variance at two minutes to close. Every row was recomputed without it and rows that could not be recomputed were deleted rather than left in.

- **Markets other than BTC.** ETH and XRP are recorded but not published here yet.

- **Hours Kalshi did not run.** Roughly 22% of hourly slots have no market on Kalshi's side. Those windows do not exist rather than being missing.


## Reproducing the accuracy figure

```python
import pandas as pd

df = pd.read_csv('sardine_history_btc_15m.csv').dropna(subset=['sardine_p'])
df['bucket'] = (df.sardine_p * 10).astype(int).clip(upper=9)

# one vote per window: a window settling high wins every strike below it
w = df.groupby(['bucket', 'window_close_utc']).agg(
        said=('sardine_p', 'mean'),
        got=('settled_yes', lambda s: int(s.mean() >= 0.5)))

b = w.groupby('bucket').agg(windows=('got', 'size'),
                            said=('said', 'mean'), got=('got', 'mean'))
b['miss_points'] = (b.said - b.got) * 100
print(b)
print('accuracy:', b[b.windows >= 200].miss_points.abs().max())
```


## Contact

Something here wrong, unclear, or contradicted by the data? jake@sardine-weather.com. A correction is worth more to us than a compliment.
