SIX Swiss match-price rules

Available in the dashboard. Run speclogician demo run six-swiss to load this spec, then open it via speclogician demo dashboard six-swiss or the VS Code extension (status-bar SpecLogician / Browser button).

This is the most regulation-heavy example in the repo. It formalizes the SIX Swiss Exchange's match-price rules: given an order book with a top-of-book buy and sell, determine the fill price according to which combination of order types (Limit, Market, Quote) is present. Each rule from the regulatory document maps one-to-one to a named transition, a scenario, and a linked doc artifact.

This is the showcase for:

  • One transition per behavioral case. No match_step monolith — each of the 9 fill rules is its own narrow transition.
  • One doc artifact per regulatory clause. Four AddDocRef entries (§3.2 Limit×Limit, §3.3 Market×Limit, §3.4 Market×Market, §3.5 Quote tie-breaking), each linked to only the scenarios it governs.
  • Variant types end-to-end. order_kind, fill_result, action_kind are all variants — the complement contains only meaningful gaps, never kind = 5.

The system

A single security trades on a single venue. Each side of the order book is a list (FIFO by time). When a match is requested:

  • Look at the top-of-book on both sides.
  • Classify the pair (buy.order_type, sell.order_type) — there are 9 combinations of Market/Limit/Quote.
  • Apply the SIX rule for that exact combination to determine the fill price.

The challenge is that the rules differ for each combination — Limit×Limit uses the older order's price, Market×Limit uses the limit side's price, Market×Market uses surrounding-order context, and Quote tie-breaking depends on quantity equality.


Domain base

type order_kind = Market | Limit | Quote
 
type order = {
  order_id    : int;
  order_type  : order_kind;
  order_qty   : int;
  order_price : int;   (* cents *)
  order_time  : int;
}
 
type fill_result =
  | NoFill
  | Filled of int     (* fill price *)
 
type state = {
  buys      : order list;
  sells     : order list;
  ref_price : int;
  last_fill : fill_result;
}
 
type action_kind =
  | ComputeMatch
  | SubmitBuy  of order
  | SubmitSell of order
 
type action = { kind : action_kind }

Helper functions (defined alongside the base) extract the top-of-book and next-of-book on each side, and compute the older order's price:

let best_buy  (s : state) : order option = ...
let best_sell (s : state) : order option = ...
let next_buy  (s : state) : order option = ...
let next_sell (s : state) : order option = ...
 
let older_price (b : order) (sl : order) : int =
  if b.order_time > sl.order_time then sl.order_price else b.order_price

Predicates

State predicates (10)

Classify the top-of-book on each side by order type, plus the fill outcome.

NameTells us…
no_buyThe buy side is empty
no_sellThe sell side is empty
buy_is_marketTop buy is a Market order
buy_is_limitTop buy is a Limit order
buy_is_quoteTop buy is a Quote
sell_is_marketTop sell is a Market order
sell_is_limitTop sell is a Limit order
sell_is_quoteTop sell is a Quote
fill_unknownThe last computed fill was NoFill
fill_knownThe last computed fill produced a price

Action predicates (7)

Classify which kind of order is being submitted, or whether the action is a ComputeMatch.

NameMeaning
is_computeComputeMatch action
is_submit_buy_limitSubmitting a Limit buy
is_submit_buy_marketSubmitting a Market buy
is_submit_buy_quoteSubmitting a Quote buy
is_submit_sell_limitSubmitting a Limit sell
is_submit_sell_marketSubmitting a Market sell
is_submit_sell_quoteSubmitting a Quote sell

Transitions (10)

Each transition pattern-matches the top-of-book pair and applies its specific SIX rule. Total functions — they fall back to the unchanged state for any combination they don't handle, which lets reachability traverse them safely from any source node.

TransitionImplements (when buy×sell type matches)
submit_buyPrepend the submitted order to state.buys
submit_sellPrepend the submitted order to state.sells
fill_no_bookOne side empty → NoFill
fill_limit_limitLimit×Limit → fill at the older order's price (§3.2)
fill_market_limitMarket buy × Limit sell → fill at the sell's limit price (§3.3)
fill_limit_marketLimit buy × Market sell → fill at the buy's limit price (§3.3)
fill_market_marketMarket×Market → fill from surrounding context clipped to ref_price (§3.4)
fill_quote_quoteQuote×Quote → tie-breaking via next_* (§3.5)
fill_quote_limitQuote buy × Limit sell → quote tie-break (§3.5)
fill_limit_quoteLimit buy × Quote sell → quote tie-break (§3.5)

Scenarios (20)

Scenarios fall into four groups:

Initial: empty book

ScenarioGivenWhenThenFlags
empty_bookno_buy, no_sellinitial

Submitting orders (build up book state)

ScenarioGivenWhenThen
submit_buy_limit_from_emptyno_buy, no_sellis_submit_buy_limitsubmit_buy
submit_buy_market_from_emptyno_buy, no_sellis_submit_buy_marketsubmit_buy
submit_buy_quote_from_emptyno_buy, no_sellis_submit_buy_quotesubmit_buy
submit_sell_limit_after_buy_limitbuy_is_limit, no_sellis_submit_sell_limitsubmit_sell
submit_sell_limit_after_buy_marketbuy_is_market, no_sellis_submit_sell_limitsubmit_sell
submit_sell_market_after_buy_marketbuy_is_market, no_sellis_submit_sell_marketsubmit_sell
submit_sell_market_after_buy_limitbuy_is_limit, no_sellis_submit_sell_marketsubmit_sell
submit_sell_quote_after_buy_limitbuy_is_limit, no_sellis_submit_sell_quotesubmit_sell
submit_sell_quote_after_buy_quotebuy_is_quote, no_sellis_submit_sell_quotesubmit_sell
submit_sell_limit_after_buy_quotebuy_is_quote, no_sellis_submit_sell_limitsubmit_sell

Matching (the actual SIX rules)

Each match scenario is a goal — reachability must demonstrate that the rule's pre-state is achievable.

ScenarioGiven (top-of-book pair)RuleThen transition
match_limit_limitbuy_is_limit, sell_is_limit§3.2fill_limit_limit
match_market_limitbuy_is_market, sell_is_limit§3.3fill_market_limit
match_limit_marketbuy_is_limit, sell_is_market§3.3fill_limit_market
match_market_marketbuy_is_market, sell_is_market§3.4fill_market_market
match_quote_limitbuy_is_quote, sell_is_limit§3.5fill_quote_limit
match_limit_quotebuy_is_limit, sell_is_quote§3.5fill_limit_quote
match_quote_quotebuy_is_quote, sell_is_quote§3.5fill_quote_quote

One-sided observation

ScenarioGivenWhenThen
only_buy_sidebuy_is_limit, no_sellis_computefill_no_book
only_sell_sideno_buy, sell_is_limitis_computefill_no_book

Artifact linking

Each regulatory clause is linked to only the scenarios it governs:

  • doc-limit-limit (§3.2)match_limit_limit plus the submit scenarios that build a limit×limit book
  • doc-market-limit (§3.3)match_market_limit, match_limit_market, plus the submit scenarios that lead there
  • doc-market-market (§3.4)match_market_market and submit_sell_market_after_buy_market
  • doc-quote (§3.5) → all match_*quote* scenarios plus the submit scenarios that build a book containing a quote
  • src-impl → all 10 transitions (the executable matching engine)

This rule-by-rule linking is the reason this demo exists — opening any scenario in the dashboard shows you exactly which clause of the regulatory document it's grounded in.


Why this design

A naïve formalization would write one match_step transition with nine nested if/match clauses dispatching on the top-of-book pair. That works for the IML, but the SpecLogician model becomes weaker:

  • The reachability graph has one edge class instead of nine — the per-rule story is invisible.
  • A bug in the §3.4 branch is buried inside a 50-line transition; fixing it touches the same block as every other rule.
  • Each clause of the regulatory doc has nothing specific to link to. You link everything to match_step or nothing.

The granular design — one transition per rule, one scenario per rule, one doc clause per rule — preserves the rule book's structure in the model itself. This is the canonical SpecLogician shape for regulation-heavy domains.

See the agent guide's Granularity discipline section for the same point made in general.


Reachability: no cycle back to empty_book

If you open this spec in the dashboard, the reachability graph fans out from empty_book through the submission scenarios into the per-rule match scenarios — and stops there. Each match_* node carries a self-loop (firing the rule from the same top-of-book pair re-computes the same fill price) but has no outgoing edge back to empty_book.

That's intentional. The matching transitions update s.last_fill to record the computed fill price but don't consume the matched ordersstate.buys and state.sells stay populated. The demo focuses on price determination (the rule-by-rule mapping from top-of-book pair to fill price), not on the full order-book lifecycle. A real exchange would also pop the matched orders off both sides and the graph would cycle back to empty_book after every fill.

For the full lifecycle view — orders resting on the book, getting consumed on a match, the book cycling back to empty, plus a safety invariant against a crossed book — see Stock exchange. The two demos divide labor cleanly: six-swiss is the rule-encoding showcase, stock-exchange is the lifecycle showcase.


Running the demo

speclogician demo run six-swiss              # builds state.json
speclogician demo dashboard six-swiss        # opens it in the browser dashboard

If you already have a state file from a previous run, you can also open it directly — either via the SpecLogician status-bar button in the VS Code extension, or by pointing the standalone speclogician-dashboard binary at the demo's state.json.

The full changes.py ships in the repo at src/speclogician/demos/content/six-swiss/changes.py (~600 lines including the IML helpers) — copy it as a starting point for any regulation-heavy domain where each clause should map to its own scenario.