Skip to content
RatterAUPublic

About

An on-course bookmaker's satchel, on a Mac — frame, docket and settle a race meeting at punter speed

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

Repository files navigation

The punters' price board: a state-coloured banner with the bookmaker's name over DARWIN, the countdown boxed on the left and RACE 10 boxed on the right, then twelve runners with their silks — win prices in lime, place prices in yellow — and three scratchings struck through in red

Bookie

A macOS app for fielding a race meeting: price the market, sell the tickets, print the dockets, settle the book.



It exists to answer one question fast enough to matter: what does this bet cost me if it wins?

Prices arrive on their own, all day, from a certain green three-letter betting agency — no login, no key, no scraping a screen.


macOS 14+ Swift 5.9 SQLite No dependencies 635 checks MIT



Why · The board · Selling · Money rules · Install · Under the hood


Why

Fielding a meeting is a speed problem wearing a maths problem's clothes. The arithmetic is not hard. Doing it while a punter is mid-sentence, the horse is shortening, and eleven other people are holding cash — that is the hard part. Every second between "fifty the win on three" and paper in a hand is a second you are not serving the next person.

So the whole app bends toward one thing: get the ticket printed without looking away from the punter. Everything else — the boards, the risk lines, the settlement — is what has to be true around that so the fast part stays safe.

I built it because the alternative is a satchel, a pen and a good memory, and a good memory pays out on a horse that ran third.


Screen recording: a busy Randwick book with 68 tickets and $9,340 held — prices moving on the rail, a stake typed, a runner typed, the ticket appearing in the recent list and the worst-case figure moving with it

Randwick R5, 68 tickets on it. Prices move, tickets land, the worst case moves with them.

The board

Win in lime, place in yellow, one race to a screen — the picture at the top of this page, on a TV turned portrait, which is how nearly every on-course screen is hung. Drag the window onto it and press ⌘F.

Nothing on that screen gives the book away. No overround, no liabilities, no held figures — the punters' screen shows prices and only prices.

The banner carries the book's own name over the track, the race number in its own box on one side and the countdown in a box on the other — the three things a punter crossing the room is trying to read, each in a fixed place so the eye learns where to go rather than re-reading a line of text.

Five things about it are deliberate and cost more work than they look:

The banner is the state's colour, not a brand colour. A book standing at a country meeting runs two screens: the track it is on, and an interstate one. Both were racing green once, so the only thing separating them was the venue name — three words, in a crowd, at fifteen metres. Now NSW is green, VIC red, SA blue, QLD purple, WA gold, no two confusable in daylight, and an unrecognised location gets a flat charcoal rather than borrowing a colour that means a state. If the banner comes out pale, the header ink flips to black on its own.

Every runner is always on screen. Row height comes from the window height divided by the field size, so a 6-runner race and a 24-runner race both fill the display and neither scrolls. What it does not do is scale text per row — one horse's name coming out smaller than the next one's is worse than a truncated name, so long names truncate.

Type is sized to the column it goes in, not just to the row. Sizing from row height alone is a guess about whether the text fits, and on a tall narrow screen the guess was wrong: 96pt prices in a 195pt cell, so every four-character price truncated to 1..... Every size is now min(what the row allows, what the column can carry), measured in ems and rounded down to whole points — and the check suite asserts it across seven pane shapes and five field sizes, so a price that would clip fails a test instead of reaching a television.

A scratching cannot be read as a price. The row goes red, the name is struck through, and both price cells say SCRATCHED rather than showing anything a punter could bet on. It keeps its place in the card, because someone counting down the numbers has to find 6 crossed off, not find 6 absent.

It shows the next bettable race, not the one being run. A race being run belongs on the television. Holding it on the price board just costs the next market its betting time. A result lingers seven minutes from the jump so the crowd can read it, then the board moves on by itself — without dragging the operator off a race they are still paying out on.

Selling

Selling screen: the race rail down the left with venues and races, the field in the middle with flucs, board prices, held money and net-if-wins per runner, two runners shaded red for liability, and the stake and runner boxes along the bottom beside W P E S bet-type keys

Stake. Runner. W. The docket prints on the keystroke — no mouse, no dialog, no confirmation.

The stake and saddlecloth boxes take digits and nothing else. That is not input validation, it is what makes the keyboard work: since a letter can never be data, a letter is free to be a command. W, P and E sell and print the instant you press them. S is a split — different win and place amounts — so it only moves the cursor to ask for the second number.

Every key is printed on its own button. You never have to remember one, and you never have to reach for the mouse to use one.

Book screen: every runner in the race with tickets written, money held, average odds taken, what it pays if it wins, what it pays if it only places, and net-if-wins — best case plus $103, worst case minus $98

The same race from the other side. Best case, worst case, and every runner in between.

A winner does not settle the race. The other place positions decide whether a rival's money gets paid too, and the horse carrying the book's biggest place liability may or may not sneak into third — which is not knowable when the bet is taken. So every runner carries two figures, not one:

The finish it assumes
If wins alone This runner home, cheapest others in the places — normally horses with nothing on them
Worst if wins The same winner, costliest others in the places

They are the range that winner can land in, and where they agree the second is dimmed, because then it is one fact printed twice. The risk budget and the OVER LIMIT alarm measure the harsh one — a risk gate must never quietly start measuring the kind number. The best and worst case chips are the ceiling and the floor of those two columns.

A single figure had to pick an end and be silently wrong about the other. It picked the harsh one, so a book holding a thousand dollars each-way on one horse reported every possible finish as a loss — when that horse running fourth is worth +$1000.

What it does

Sell four ways on one keystroke. Win, place, win-and-place at equal stakes, or a split at different stakes. Every bet is a (win, place) pair underneath, so there is one settlement path and not four.

Frame the market yourself. Nudge a price two rungs under the feed and it stays two rungs under as the feed moves all day — because a bookmaker means "under the market", not "at 4.50". Or type a price and freeze it. Take a whole race off the feed and price the lot by hand.

Keep trading when the feed dies. No prices means you type them. There is deliberately no failover to a worse source, because a wrong price is worse than no price. When the feed comes back it takes the board back automatically — but only for races that went manual because of the outage, never one you chose to frame yourself.

Run up to four TVs at once. Each screen follows its own venue and moves through that card by itself, or gets pinned to one race for the whole day — the Cup up on one screen while another works through the rest of the meeting at the same track. A pinned screen ignores auto-follow and stays put even if the venue comes off the rail.

Watch the exposure move. Per-runner liability, worst case, best case and your top-liability runner all update on every ticket, and the row shades red as it gets expensive. There is a risk budget that scales with the day's actual P/L rather than sitting at a number you picked this morning.

Settle itself. Results land, winners are paid, refunds handled, dead heats split. A protest re-settles and writes an adjustment row for anyone already paid, rather than quietly rewriting a payment that already went over the counter. A wrong result can be unresulted: unpaid tickets go back to open, nothing settles the race again (not even the feed) until you enter the correct placings, and anyone already paid gets an adjustment row.

Field a meeting nobody publishes. Picnic and bush tracks that no feed carries at all: type the field in, price it, sell it, settle it. Same code path as any other race.

Print on real paper. Any USB receipt printer, over raw USB — no CUPS driver, no print dialog, no lpr. Standard USB printer-class devices are found on class alone, whoever made them; Epson's TM series and the other common POS brands hide behind a vendor-specific interface and are recognised by vendor. Two printers plugged in, or an unusual one? Pick it by hand in Manage. Plug it in and it prints.

Reconcile at the end. Turnover, payouts, what is still in punters' pockets, and a day sheet you can pin to the bag. The unpaid list and the day's outstanding figure must agree, and if they ever don't, that is a bug you want to hear about immediately.

The money rules

These are the parts that cannot be wrong. Every one is pinned by an assertion in the check suite.

Place terms lock at the sale. Under 5 runners pays no places, 5–7 pays two, 8+ pays three — and whatever was true when the docket printed is stored on the ticket. Settlement pays max(terms sold, terms final), so a punter is never worse off than the paper in their hand.

A late scratching is a deduction, never new terms. Any scratching after the sale returns the stake in full and cuts only the profit. Moving the terms instead would rewrite a contract that is already in someone's pocket. Deductions apply to tickets sold before the scratching landed; the ones after it were priced on the smaller field already.

Scratchings are never scraped. A guessed scratching is a wrong refund. You scratch by hand — right-click the runner — and it persists across polls and restarts.

Dead heats split properly. N runners dead-heating for S positions means each backer wins S/N of their stake at full odds and loses the rest. Not the full stake. This is the one that is a money bug rather than a display bug.

Settlement is idempotent, in SQL. Double payment is blocked by a WHERE paid = 0, not by a hopeful if. The race result is written once and a second attempt is a no-op.

Payouts round up to 5c, always the punter's way. Nobody counts single cents across a counter, and a house that rounds its own way on every ticket is quietly skimming.

Ticket numbers are plain integers from 1, forever. A punter shouts their number across a crowd. It has to be sayable.

The odds ladder is written out by hand, and that's on purpose

The obvious implementation is a set of from/to/step bands: 1c steps at odds-on, 5c to evens, 10c to 2/1, and so on. It is also wrong, because the real ladder is irregular at both ends:

1.10 → 1.12 → 1.14 → 1.15 → 1.16 … 1.24 → 1.25 → 1.26     1.15 and 1.25 break a 2c run
  21 →   23 →   26 →   27 →   31 …   81 →   91 →  101     27 and 91 break the pattern

No arrangement of bands reproduces that, and a generator that came close would quietly price runners onto rungs that don't exist. So the table is written out verbatim — 106 rungs — and the table is the specification. It is also the calibration knob: adjust it there and win prices, place prices, nudges and snapping all follow.

Place prices move in probability space, not price space

Shorten a runner's win price and its place price has to follow. The tempting move is to scale the place price by the same proportion. That is badly wrong for favourites.

Place probability does not respond proportionally to win probability. A 40/1 shot that firms to 30/1 improves its place chance by roughly the same proportion — but a horse already 90% certain to place cannot gain much no matter what you do to its win price. Scaling proportionally slashes a favourite's place price every time you touch it.

So the market's own place price stays as the anchor, and it moves by exactly the ratio a model says the place chance changed by — a Discounted Harville model with a Henery discount of λ = 0.76, run twice over the same field, once on the feed's win prices and once on the board's. The ratio is taken on fair odds rather than bookmaker odds, because moving any price changes the overround, and a bookmaker ratio would smear that shift across every runner and report horses moving when their chances hadn't.

Install

Command Line Tools only. Full Xcode is neither needed nor used.

xcode-select --install            # if you haven't already

git clone https://github.com/RatterAU/Bookie.git
cd Bookie
make mock                         # synthetic fields and prices, no network

Mock mode is a complete working book — sell into it, settle it, pay tickets out, none of it touches anything live. When you want the real thing, make app and open Bookie.app.

Important

Bookie.app is a snapshot of whenever make app last ran. It does not update itself, and the app will tell you in the title bar when the source has moved on without it.

All the make targets
Command What it does
make run Run from source against live data
make mock Run against the mock feed — no network, safe to experiment
make test The assertion suite (bookie-check)
make app Release build, packaged into Bookie.app
make open make app, then launch it
make clean Wipe build products and the bundle
Printers: what works, and how one is found

There is no CUPS queue, no driver and no print dialog. The app opens the printer's USB interface itself and writes ESC/POS to the bulk-OUT endpoint, which is why a ticket prints on the keystroke instead of after a spooler thinks about it.

Two kinds of printer answer:

Interface class Who How it's found
0x07 — USB printer class Most 80mm receipt printers On class alone, any vendor, no list to be on
0xFF — vendor-specific Epson TM series, and the common POS brands By vendor id — Epson, Star, Bixolon, Citizen, SNBC, Custom, Zjiang, Rongta and the generic Winbond/STMicro boards

Vendor-specific interfaces are not self-describing — USB-serial adapters and debug interfaces live at 0xFF too — so those are taken from the known list rather than written to blind. If yours isn't on it, or two printers are plugged in, Manage ▸ Printer lists what's connected and pins one by vendor and product id, which survives a replug and a reboot.

Paper is 80mm, Font A, 40 printable columns.

Why there's no SwiftData, no @Observable and no XCTest

Every one of them needs a macro or test plugin that Command Line Tools alone cannot load. Rather than require a full Xcode install to build an 11,000-line app, the project does without:

Instead of It uses
SwiftData / @Model Raw SQLite3 through a thin wrapper (DB.swift)
@Observable Combine ObservableObject
XCTest / swift-testing A plain executable of assertions that exits non-zero on the first failure

This is a constraint the codebase is built around, not a limitation it apologises for. It also means make test runs in about a second.

First day

Name yourself. A prompt asks once for the bookmaker name. It prints across the top of every ticket and heads every TV, so the paper in a punter's hand and the screen they read the price off cannot disagree. A docket without a name on it is not a docket anyone can bring back.

Pick your venues. The rail starts empty on purpose. It is an allow-list, not a deny-list — the card sources sweep the whole country, and a deny-list would silently walk fifty tracks onto your screen the moment one of them published late.

Sell. Stake, runner number, then a letter.

⌘1 Selling Field, prices, stake entry, live risk
⌘2 Price board Punter-facing · one window per TV, up to four
⌘3 Manage Tickets, payouts, the book, day sheets, history
⌘4 Vision Race broadcast beside the selling screen
⌘⌥1…⌘⌥4 Follow a TV Write tickets for the race on that screen (⌘⇧ is macOS screenshot, so it isn't used)
W Win sells and prints on the keystroke
P Place sells and prints on the keystroke
E Win place equal both legs, sells and prints on the keystroke
S Split different amounts — focuses the place box for the second number
⌘Z Void takes back the last sale, while the race is still open

Under the hood

Cards come from several places and prices come from one. They are assembled per venue rather than merged, because a race id is source-specific and a sold ticket has to stay bound to the card it was written against.

flowchart LR
    FEED["Price feed<br/><i>green, three letters</i>"] --> RAIL
    RA["Racing Australia<br/><i>cards only, never prices</i>"] --> RAIL
    HAND["Hand-entered<br/><i>bush + picnic tracks</i>"] --> RAIL

    RAIL(["SourceSelection<br/><i>pure · tested</i>"]) --> ENGINE

    ENGINE["AppEngine<br/><i>@MainActor · single source of truth</i>"] --> LADDER{{"Odds ladder<br/>+ margin + overrides"}}
    LADDER --> BOARD["TV boards"]
    LADDER --> SELL["Selling screen"]
    SELL --> STORE[("TicketStore<br/><i>every cent that moves</i>")]
    STORE --> SETTLE["Settlement<br/><i>idempotent</i>"]
    STORE --> PRINT["ESC/POS over USB<br/><i>any receipt printer</i>"]
Loading

Four decisions carry most of the weight:

  • One venue, one key. Sources shout their venue names differently — MORPHETTVILLE from one, Morphettville from another, whatever was typed for a hand-entered card. Three spellings put the same meeting on the rail three times, so every venue lookup in the app goes through one canonical key of uppercase alphanumerics.
  • A stale source choice falls back rather than emptying the rail. Pick a source yesterday, have it not carry the venue today, and the rail must still show the meeting off whatever does carry it. Likewise a per-race override that the other source cannot answer is ignored rather than dropping the race — dropping a race takes its betting with it.
  • The engine is the only source of truth. Views read it and call it. No view holds its own copy of the day, and no view does money arithmetic.
  • History rebuilds from stored cards, not from the feed. The moment money goes on a race, its card is snapshotted into the database. That snapshot is what a past race reopens from — field, prices sold, liabilities, result — long after the feed has dropped the day, or for a bush meeting that was never on a feed at all.
Things that only show up on a real race day

Most of the awkward parts of this codebase exist because something behaved differently at a track than it did at a desk.

What's actually true What the app does about it
The racing signal can trail the actual jump by up to ~45 seconds, and races run late besides Polling goes hard from 90 seconds before the scheduled jump until five minutes after it, rather than giving up on time — giving up early is what leaves a board reading OPEN on a race already past the post
A price board that stops updating looks exactly like a price board that agrees with you The age of the last successful update is shown next to the feed badge, and a price that has gone stale is labelled with how old it is instead of sitting there looking live
A one-second countdown on the shared engine republished every window once a second — every silk, both boards, every runner row, 60 times a minute The clock is a separate object that only the handful of views showing elapsed time observe
Silk images are PNGs with white backgrounds The board uses hard near-black rules between rows rather than alternating row shading, which would make every silk look like it was floating in a box
A meeting can be abandoned with money already on it, and an abandoned race never gets placings Abandonment is settled explicitly and refunds the race, rather than leaving every ticket open forever waiting for a result that is not coming
Someone will scratch a horse on a race you are not looking at Scratchings are recorded from every race on screen and once more as a race settles itself — with no record, settlement applies no deduction and the whole race pays out on the old, bigger field
Source layout
Sources/
  BookieCore/        no UI, fully testable
    Models.swift       Race, Runner, Ticket, bet types
    DB.swift           raw SQLite3 wrapper
    TicketStore.swift  every cent that moves
    Pricing.swift      odds ladder, margin engine, place model
    SourceSelection.swift  which source answers for which venue (pure)
    BoardPicker.swift  which race a TV shows (pure)
    Printer.swift      ESC/POS over raw USB, any receipt printer
    BoardLayout.swift  every width and type size on a TV (pure)
    Reports.swift · DaySheet.swift · StateColours.swift · Format.swift
  Bookie/            SwiftUI
    AppEngine.swift    @MainActor, single source of truth
    SellingView · BoardView · ManageView · ResultsView
    HistoryView · UnpaidView · WatchView · CustomVenueView
  bookie-check/      assertions — money paths live here

What a ticket looks like

Real output, not a mock-up. Every outcome spelled out in words and dollars, because a punter should never have to do arithmetic to know what they are holding:

            J. SMITH RACING
========================================
              MUSWELLBROOK
                 RACE 1
========================================
                TICKET 1
========================================
                #3 ZORKO
             $50 WIN PLACE
----------------------------------------
             $50 WIN @ 4.50
            $50 PLACE @ 1.70
             3 PLACES PAID
----------------------------------------
              IF WINS PAYS
                  $310
          IF PLACES ONLY PAYS
                  $85
----------------------------------------
        26 Jul 2026 10:24:46 am
        RETAIN TICKET TO COLLECT
----------------------------------------
           GAMBLE RESPONSIBLY
  Think. Is this a bet you can afford
                to lose?
       Gambling Help 1800 858 858
       gamblinghelponline.org.au
                18+ ONLY

Print one without a printer attached:

BOOKIE_RECEIPT=1 swift run bookie-check

The price is quoted the way the board called it, and the responsible-gambling footer is covered by an assertion so it cannot quietly disappear in a refactor.

Tests

make test                                # 635 assertions, no network
BOOKIE_LIVE=1 swift run bookie-check     # opt-in: hits the real endpoints

The suite is aimed squarely at the money: place terms, deductions, dead heats, refunds, abandonment, double-pay protection, re-settlement after a protest, unresulting and re-entering a result, and the reconciliation between the unpaid list and the day's outstanding figure. Touch settlement, add a check.

Where your data lives

What Where
Tickets, results, adjustments ~/Library/Application Support/Bookie/bookie.sqlite
Hand-entered meetings ~/Library/Application Support/Bookie/custom-meetings.json
Exported day sheets ~/Documents/Bookie Day Sheets/

All of it local. No account, no backend, no telemetry, nothing uploaded anywhere.

Warning

If the ticket database cannot be opened, the app says so in red and runs in memory rather than pretending. Tickets will not survive a relaunch, and you will be told that rather than discovering it.

What this is not

Not a betting platform, not an exchange, and not a way to take money over the internet. It is the software half of a satchel: it prices, it sells, it prints, it settles, and it tells you what you are carrying.

It is also not a licence. Bookmaking is a licensed activity in every Australian state, and complying with your regulator's rules on pricing, record-keeping and payouts is entirely yours. This project is not affiliated with, endorsed by, or connected to any wagering operator, racing body or broadcaster; it reads public endpoints the same way a browser does.

Card data © Racing Australia. Code is MIT licensed — see LICENSE. Contributing? CONTRIBUTING.md is the short list of invariants this codebase will not bend on.


Gamble responsibly.

Gambling Help 1800 858 858 · gamblinghelponline.org.au · 18+


Built for the rails, not the boardroom. 🇦🇺

About

An on-course bookmaker's satchel, on a Mac — frame, docket and settle a race meeting at punter speed

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages