Fee Schedule — Data Standardization & Normalization Spec

Rulebook Company · Jane Street delivery pipeline · proposal v1 (2026-07-09)

Author: Wazir (data engineering) · grounded in the 80-version scoring batch — 23,375 fees analysed

Goal: queryable · actionable · anomaly-proof delivery

Why this matters

The scoring batch put a number on extraction quality (avg correctness 95.2, completeness 99.5) — but it also exposed where the data model itself lets anomalies through. The single biggest root cause is that fee_amount is a free-text string. Across 23,375 fees we see the same economic value written a dozen ways, and the sign of a rebate depends on parentheses that aren't always applied.

The core problem: a free-text amount can't be validated, summed, compared, or safely delivered. Jane Street needs a schema that is machine-checkable at write time so the anomalies are caught before delivery — not after scoring.

Real value diversity we measured in one column (fee_amount)

PatternReal examples from the dataProblem
Plain dollar$0.50, $1,000, $500thousands separators, no unit
Rebate (parenthesised)($0.25), ($0.02), $ (0.0020)sign encoded by punctuation; inconsistent spacing; sometimes dropped → rebate stored positive
Sub-cent / spacing noise$.005, $.00040, $ (0.0016)leading-zero + whitespace variants of the same number
Per-share w/ basis$0.000035 per executed equivalent share, $0.0015 per shareunit + basis buried in prose
Percentage w/ basis0.15% of Dollar Value, 0.025% to 0.035% ADVpercentage vs flat mixed in one field; ranges
Recurring$0.15/month, $0/monthrecurrence buried in string
Non-numericvaries (202×), No Charge, N/Ano value, no reason, no formula — un-actionable

Same story in the tier columns: fee_monthly_volume holds 0-24,999 contracts, 0 to 199,999, 0.025% to 0.035% ADV, 0 - 1,500,000 Users — four different metrics and units in one text field. And fee_participant has a catch-all "Other" (4,520 rows) sitting next to real roles.

1 · Canonical fee record (normalized)

Keep the current row identity, but split every semantically-overloaded string into typed columns with controlled vocabularies. Free text is preserved as *_raw for provenance, never used for computation.

FieldTypeRule / vocabulary
fee_iduuidstable per atomic fee
extraction_id / version_iduuidprovenance to the delivered version
source_chunk_indexint NOT NULLevery fee MUST map to a source chunk — kills fabricated rows
fee_categoryenumfees_and_rebates · market_data · port_and_services · legal_regulatory · participant · membership
fee_actionenumadd · remove · route · cross · flat · na
directionenumcharge · rebate · credit — the single source of sign truth
amount_valuedecimal(18,8)signed; rebate/credit ⇒ negative. NULL only if amount_type≠fixed
amount_typeenumfixed · percentage · formula · tiered · varies · free
amount_unitenumper_contract · per_share · per_trade · percent · flat · per_month · per_port · per_user
amount_basisenum / nulldollar_value · adv · executed_equivalent_share · notional · null
currencychar(3)default USD
participantenumcustomer · professional_customer · market_maker · away_market_maker · firm · broker_dealer · all — no "Other" (see §3)
symbol_classenumpenny · non_penny · all · specific
symboltextcontrolled where possible (SPY/QQQ/IWM); free otherwise
tier_metricenum / nullmonthly_volume · adv_pct · contracts · users · performance
tier_lower / tier_uppernumeric / nullstructured bounds — replaces "0-24,999 contracts"
effective_datedate / nullISO-8601
fee_amount_rawtextoriginal string, kept verbatim for audit
correctness / completenessint 0–100the two stored scores — first-class, drive delivery gating (§6)

2 · The amount grammar (parse once, at write)

A deterministic parser normalizes fee_amount_raw → (amount_value, amount_type, amount_unit, amount_basis, direction). Rules, in order:

  1. Sign: parentheses or fee_charge_type/category = rebate/credit ⇒ direction=rebate, amount_value < 0. This makes the sign explicit instead of relying on punctuation.
  2. Strip & canonicalize: remove $, spaces, thousands commas; normalize .000400.0004.
  3. Unit/basis from trailing prose: per share→per_share, per executed equivalent share→per_share + basis=executed_equivalent_share, /month→per_month, % of Dollar Value→percent + basis=dollar_value, ADV→basis=adv.
  4. Ranges (0.025% to 0.035%) ⇒ amount_type=tiered; expand into rows or store bounds.
  5. Non-numeric (varies, No Charge): No Charge/Free/$0amount_value=0, amount_type=fixed. variesamount_type=varies and a required formula/note or it fails validation.

Worked examples — raw → normalized

// rebate, buried sign
raw: "($0.25)"
→ amount_value: -0.25
  direction: "rebate"
  amount_type: "fixed"
  amount_unit: "per_contract"
// percentage + basis
raw: "0.15% of Dollar Value"
→ amount_value: 0.15
  amount_type: "percentage"
  amount_unit: "percent"
  amount_basis: "dollar_value"
// micro per-share
raw: "$0.000035 per executed
      equivalent share"
→ amount_value: 0.000035
  amount_unit: "per_share"
  amount_basis:
    "executed_equivalent_share"
// un-actionable → must justify
raw: "varies"
→ amount_type: "varies"
  amount_value: null
  formula: REQUIRED
  // else validation fails
// tier text → bounds
raw vol: "0-24,999 contracts"
→ tier_metric: "contracts"
  tier_lower: 0
  tier_upper: 24999
// recurring
raw: "$0.15/month"
→ amount_value: 0.15
  amount_unit: "per_month"

3 · Controlled vocabularies

Every categorical column resolves to a fixed enum. Unknown values are not silently stored — they route to a review queue. This is what removes the "Other" (4,520) and N/A bleed.

Participant

customerprofessional_customermarket_makeraway_market_makerfirmbroker_dealerall

"Other" and "Professional"(ambiguous) map through a lookup; anything unmapped ⇒ review, never delivered.

Category

fees_and_rebatesmarket_dataport_and_serviceslegal_regulatoryparticipantmembership

amount_type

fixedpercentageformulatieredvariesfree

amount_unit

per_contractper_shareper_tradepercentflatper_monthper_portper_user

4 · Validation & anomaly-prevention rules

These run as CHECK constraints + a pre-delivery validator. Each maps to a real anomaly the scoring batch found.

#RuleAnomaly it prevents (seen in batch)
V1direction='rebate' ⇒ amount_value ≤ 0 and vice-versaCboe BZX/EDGX rebates stored positive (G=116–127, 157–165)
V2source_chunk_index NOT NULL & must exist in that version's chunk setFabricated rows — Cboe BZX G=34/35, EDGX "Retail Membership" tiers, $850 cost
V3amount_type='varies' ⇒ formula/notes required202× bare varies with no value or reason
V4participant ∈ vocab (else review queue)4,520 "Other" rows; ambiguous Professional
V5amount_value within category plausibility band (e.g. per-contract |x|≤$10)MIAX Emerald G=319 $0.45 vs source $0.75; typo amounts like $1,00.00
V6No duplicate atomic key (participant×symbol×tier×action×direction) per versiondouble-counted rows in complex-order grids
V7Completeness reconcile: count(extracted) ≥ stated_atomic_fees per chunkMIAX Options chunks 234/244 (0%), PHLX ch103 (30%)
V8Tier continuity: no gaps/overlaps in [tier_lower,tier_upper)SQF/permit discount tiers partially extracted (only tier 4)
Footnote-signal exception (per Haris): where an amount legitimately restates a footnote figure (e.g. PHLX $0.50 vs $0.506), V5 tolerance + a source_footnote_ref field record it as correct — so the scorer doesn't false-flag it.

5 · Why this makes it queryable & actionable

Once amounts are typed and signed, the questions Jane Street and the team actually ask become one-line SQL instead of string-parsing:

-- effective maker rebate per venue (rebates are already negative)
SELECT exchange, AVG(amount_value) FROM fee
WHERE fee_action='add' AND direction='rebate' AND amount_unit='per_contract'
GROUP BY exchange;

-- every fee that changed vs the prior delivered version
SELECT f.* FROM fee f JOIN fee p USING (atomic_key)
WHERE f.version_id=$new AND p.version_id=$prev AND f.amount_value <> p.amount_value;

-- delivery gate: block anything low-confidence or unvalidated
SELECT version_id FROM fee
GROUP BY version_id
HAVING MIN(correctness) < 60 OR bool_or(source_chunk_index IS NULL);

None of these are possible today because WHERE amount < 0 can't be trusted and "varies" breaks aggregation.

6 · Confidence-gated delivery

The two stored scores become an operational gate, not just a report:

From this batch: 76/80 versions are Green, ~4 Amber (GEMX, LTSE, NYSE National, EDGX equities), 0 Red once the empty-fetch was re-run.

7 · Rollout

  1. Add typed columns alongside existing strings (non-breaking); backfill via the amount parser over all delivered versions.
  2. Shadow-validate — run V1–V8 in report-only mode for one delivery cycle; tune plausibility bands + participant lookup.
  3. Enforce — flip CHECK constraints on; wire the confidence gate into the SFTP delivery step.
  4. Re-scorefee-schedule-scoring-sftp is idempotent, so re-running after normalization refreshes scores cleanly with no double-writes.
Net effect: the extractor can still make mistakes, but a mistake now cannot be delivered — it fails a constraint or trips the gate first. That's the difference between "95% correct on average" and "nothing wrong ships."