Fee Data — Quality & Normalization Design

v2 · data-grounded · Rulebook Company → Jane Street delivery · 2026-07-31

Profiled against prod: 163,624 live fee rows (fee_schedules_result, 165,363 incl. soft-deleted) + the live FTPS delivery contract.

Goal: harden the delivery so Jane Street never breaks + raise data quality with enums & typed decomposition

The headline (read this first)

The amount decomposition Jane Street needs already exists — but in the wrong place. The delivered JSON (schema_version 2.0) already ships fee_amount_typefee_amount_valuefee_amount_min_valuefee_amount_max_valuefee_amount_signfee_amount_unitfee_amount_currency — but the database stores only the raw fee_amount text. Those typed fields are parsed on the fly at delivery time, unvalidated, and never persisted.

Why that's the risk to Jane Street: a delivery-time parser with no stored source-of-truth and no constraints means a mis-parse ships silently — wrong fee_amount_value, wrong fee_amount_currency, wrong sign — with nothing to catch it. There is no DB guarantee that the raw string and the parsed number agree. The fix is to move the parse left: persist the decomposition into typed, enum-constrained, validated columns, and have the serializer read stored fields instead of re-deriving them per delivery.

The design in one line: keep the exact delivery contract JS already consumes, but back every parsed field with a stored, enum-typed, CHECK-constrained column — so the delivery becomes a deterministic read of validated data, not a live re-parse.

1 · What the data actually looks like (measured)

1.1 The amount field is the core debt — but it's 88.6% clean

Format bucketRowsShare
plain number ($0.50, ($0.25), $1,000)144,89088.6%
number + extra text ($0.20 per side, $ (0.0020))12,1917.5%
percentage / bps (0.15% of Dollar Value, 0.398 bps)3,1291.9%
non-actionable (varies, N/A)2,1761.3%
other free-text8430.5%
value + unit prose3950.2%
Sign lives in punctuation for 25,469 fees (15.6%) — every rebate/credit whose negativity is encoded only by parentheses or a leading minus. One dropped paren = a rebate delivered as a positive charge. This is the single highest-impact correctness risk and the root of the Cboe BZX/EDGX defect cluster from the scoring batch.

1.2 It's multi-currency — the USD assumption is wrong for ~7%

CurrencyRowsShareExamples in data
USD / bare number152,47493.2%$0.50, 1,000
unknown / unattributable4,5452.8%parser can't confidently assign a currency
ZAR (South Africa)3,2522.0%R1,295.99, R3 074.69
EUR3,1411.9%€0.15 bps, min €1.6, 1,500 EUR
GBP1670.1%£200
NOK450.0%NOK 3.21

Also in the wild: 2,375 bps-denominated amounts and 228 compound expressions (min/max/greater-of/€6k /100m). The 2.8% "unknown" bucket is the dangerous one — those are shipping today with a best-guess currency and no guard.

1.3 Enums already exist — but two are dominated by the "Other" catch-all

ColumnTypeValuesCatch-all load
fee_actionenumMake, Take, Open, Routed, Other, CloseOther = 58.9% (96,316)
fee_participantenumCustomer, Firm, Broker_Dealer, Market_Maker, Away_Market_Maker, Professional, Other, AllOther = 18.8% (30,746)
fee_symbol_classificationenumETF, ETN, Equity, Index, N_A, AllN_A = 2.2%
fee_symbol_typeenumPenny, Non_Penny, N_A, BothN_A = 4.6%
fee_trade_typeenumIndex_Options, Multiply_Traded_Options, Simple_Order, Complex_Order
fee_categorytext (not enum)6 values only: Fees And Rebates, Market Data Fees, Port Fees And Other Services, Legal Regulatory Fee, Participant Fee, General Provisionsclean → promote to enum
fee_charge_typetext6,530 distinctdescriptive label only — a cross-check that flags sign disagreements for review; NOT authoritative, NOT a tiebreaker (EDGA proved it can be wrong)
Read on the enums: the schema instinct is right — 5 columns are already typed. But fee_action='Other' at 59% means that enum isn't modelling reality (the true action is buried in fee_charge_type/fee_action_details); and fee_participant='Other' at 19% is a taxonomy gap. fee_category is a perfect 6-value enum still typed as text.

1.4 Sparse free-text columns (null rates)

fee_basis 50.7% null · fee_monthly_volume 66.1% · fee_monthly_volume_criteria 68.1% · fee_conditions 56.5% · fee_action_details 36.0%. The two monthly_volume* columns mix units wildly (contracts, users, %, ADV, shares) — tier data trapped in prose.

1.5 Baseline (delivered) cut — the numbers that ship to Jane Street

The figures above are all-versions. The delivery ships baseline only (view_fee_schedule_results, is_baseline=true) — 75,426 rows / 45 venues. Delivery-accurate stats:

MetricBaseline (delivered)
plain-number amounts92.5%
non-actionable (varies/N/A)1.2% (880)
sign by parentheses/minus only18.1% (13,647) — higher than all-versions
currency USD / unknown / EUR / ZAR / GBP·NOK95.6% / 1.9% (1,421) / 1.5% / 0.9% / 0.1% · bps 827
fee_action='Other'60.0%
fee_participant='Other'18.2%
fee_category distinct5 (baseline)

2 · The delivery contract (what must not break)

Jane Street consumes, per exchange/day: detailed_fee_<date>.json = { metadata, fees[] }, plus an index.json manifest.

// index.json
{ "schema_version": "2.0", "generated_at": "…Z", "total_records": 558,
  "amount_types": { "flat": 548, "range": 0, "formula": 0, "varies": 10, "not_applicable": 0 },
  "suppliers": [ { "exchange_name": "…", "record_count": 311,
                   "confidence": { "average": 99.5, … } } ] }
// each fees[] record (34 keys) — already carries the decomposition + confidence
record_id, exchange_name, scraped_time, version_id, fee_type,
fee_category, fee_charge_type,
fee_amount, fee_amount_type, fee_amount_value, fee_amount_min_value,
fee_amount_max_value, fee_amount_sign, fee_amount_unit, fee_amount_currency,
fee_action, fee_action_details, fee_basis, fee_participant, fee_participant_details,
fee_monthly_volume, fee_monthly_volume_criteria, fee_symbol_classification,
fee_symbol_type, fee_trade_type, fee_symbol, fee_excluded_symbols,
fee_conditions, fee_exclusions, fee_notes, fee_extra_info,
changed, confidence, relationships
This is the safety key: the contract already exposes fee_amount_type/value/min/max/sign/unit/currency + confidence. Persisting those as stored, validated columns changes nothing in the delivered shape — same keys, same JSON — it only makes the values trustworthy. So the core hardening is backwards-compatible by construction.

3 · Target model — persist & constrain the decomposition

Add stored columns on fee_schedules_result (keep raw fee_amount forever for audit). Every one already has a delivery-side equivalent, so the serializer just switches from "parse now" to "read column".

ColumnTypeRule
fee_amount (existing)textraw string, immutable audit trail
fee_amount_typeenum flatrangeformulapercentagevariesnot_applicablepromote the delivery taxonomy to a stored enum
fee_amount_valuenumeric(18,6)SIGNED; rebate/credit ⇒ negative. Required when type ∈ {flat, percentage}
fee_amount_min_value / _max_valuenumeric(18,6)required when type = range; min ≤ max
fee_amount_signenum chargerebatecreditthe single source of sign truth — no more parentheses-only encoding
fee_amount_unitenum per_contractper_shareper_tradepercentbpsflatper_monthper_portparsed from trailing prose / basis
fee_amount_currencyenum USDEURGBPZARNOKwas silently guessed — make it explicit + validated; unknown ⇒ review, never ship
fee_categoryenum (6 values)promote from text — zero data loss (already only 6 distinct)
fee_amount_basisenum / null dollar_valueadvnotionalexecuted_sharefor percentage/bps amounts
completeness_score / correctness_scoreint 0–100already delivered as confidence — make them first-class (drives the gate, §5)

Existing enums stay; two get work: fee_action — add the real actions hiding in Other (59%) by mining fee_charge_type (e.g. routing, surcharge, cross, marketing); fee_participant — resolve the 19% Other via a value→enum alias map (e.g. Professional Customer, DPM, Lead Market Maker).

4 · Validation constraints (each ties to a measured anomaly)

#CHECK / validator ruleGuards (measured)
V1fee_amount_sign='rebate'/'credit' ⇒ fee_amount_value ≤ 0 and vice-versa25,469 fees (15.6%) sign-by-punctuation only
V2fee_amount_currency ∈ enum; unknown ⇒ review queue, never delivered4,545 (2.8%) currency-unattributable + 6.8% non-USD
V3type='flat' ⇒ value NOT NULL; type='range' ⇒ min ≤ max NOT NULL; type='varies' ⇒ notes/formula required2,176 bare varies/N/A
V4parsed fee_amount_value must reconcile to the numeric core of raw fee_amount (± tolerance)the whole "unvalidated delivery-time parse" risk
V5chunk_id/chunk_index NOT NULL & resolvable → provenancefabricated-row risk from scoring batch
V6fee_category / all enum cols reject unknown labels at writekeeps the 6-value category + enums clean
V7per (version × chunk): completeness reconcile — extracted ≥ stated atomic feesMIAX Options ch234/244 (0%), PHLX ch103 (30%)
V8sign-vs-fee_charge_type disagreement → flag for source review before delivery (never auto-resolve)the EDGA class — see below
Footnote-signal exception (Haris's point): where a value legitimately restates a footnote figure (PHLX $0.50 vs $0.506), a source_footnote_ref + V4 tolerance records it as correct so the scorer doesn't false-flag it.

Direction rule (locked — matches what goes to Jane Street)

V8 in practice — the EDGA sweep (run 2026-07-31, 75,426 delivered rows)

Jane Street found 6 EDGA rows ((0.00270)/(0.15000%) parenthesised = rebate, but fee_charge_type='Fee'). Verified in prod: the sign is correct (traces to Cboe's "Fee/(Rebate)" columns; code W removes = plain 0.00300 charge confirms the bracket convention is Cboe's own) and fee_charge_type='Fee' is the mislabel. Running V8 across all delivered rows surfaces the siblings before the client does:

5 · Confidence-gated delivery

The scores already ride in the delivery as confidence. Make them an operational gate on the serializer:

6 · Rollout — backwards-compatible, JS never sees a break

  1. Add the stored columns (nullable) + enums alongside the raw text. Non-breaking DDL.
  2. Backfill with a deterministic amount parser over all 163,624 live rows; diff the result against today's delivery-time parse — same JSON keys, so any divergence is a real parser bug to fix, not a contract change.
  3. Shadow-validate V1–V7 in report-only for one delivery cycle; tune currency detection + participant/action alias maps against the residuals (esp. the 2.8% unknown-currency + 59% action=Other).
  4. Flip the serializer to read stored columns instead of re-parsing. Byte-compatible output ⇒ schema_version stays 2.0.
  5. Enforce CHECK constraints + wire the confidence gate into the SFTP delivery step. Additive changes (new enum values, new fields) ⇒ bump to 2.1 under an additive-only contract rule; never remove/rename a delivered key.
The guarantee to Jane Street: the delivered shape is frozen and only ever grows additively; every value they receive is now backed by a stored, enum-typed, constraint-checked column and gated on confidence. A parser mistake can no longer ship — it fails V4/V2 or trips the gate first. That's the difference between "the delivery usually parses right" and "nothing wrong can be delivered."

7 · Priority order (highest ROI first)

  1. Persist + constrain fee_amount_value + fee_amount_sign — kills the 15.6% sign-in-punctuation risk, the biggest correctness exposure.
  2. Stored fee_amount_currency enum + V2 — stops the 2.8% unknown / 6.8% non-USD from silently shipping.
  3. V4 raw↔parsed reconciliation — the guard that makes the delivery parse trustworthy.
  4. Promote fee_category to enum — trivial, zero-loss, immediate.
  5. Re-derive fee_action (59% Other) + participant alias map — the biggest queryability upgrade.
  6. Structured tiers from fee_monthly_volume* — last, highest effort, mostly-null today.

8 · Implementation effort & phasing

Smaller than it looks — the hard parts half-exist (delivery already parses amounts; 5 enums already there).

Phase 1 — The hardening ("Jane Street can't break")

Persist + validate the amount decomposition, currency safety, raw↔parsed guard, promote fee_category, confidence gate, flip the serializer to read stored columns.

WorkstreamEffort
Amount parser (extract existing delivery logic → shared module; add currency/bps/compound)3–5d
DDL + enum types + Alembic migration1–2d
Backfill 163k rows + diff vs today's delivery parse (find real bugs)2–3d
V1–V7 constraints + pre-delivery validator (shadow report-only)2–3d
Serializer switch + confidence gate, byte-compat check2–3d

≈ 10–16 person-days → ~2–3 weeks (1 engineer), ~1.5 weeks (2 engineers). Plus one delivery cycle of shadow-validation calendar time (waiting, not work).

Phase 2 — Enum enrichment (fee_action 59% Other, participant aliases)

Bigger/fuzzier — the fix lives in the extraction pipeline (the AI assigns these): new taxonomy + prompt/logic changes + re-run. ≈ 4–6 days + ongoing tuning. Needs Muizz/Haris's eyes.

Phase 3 — Structured tiers (fee_monthly_volume)

Lowest ROI (66% null), highest parse effort. ≈ 3–4 days. Optional / defer.

Bottom line — Phase 1 only: ~2–3 weeks solo (the whole "don't break JS" payoff for a third of the effort). Everything (1+2+3): ~4–5 weeks solo, ~2.5–3 weeks with two. Long-pole = amount-parser edge cases (2.8% unknown-currency + 228 compound). The mechanical Phase-1 work can be agent-driven, dropping calendar time toward ~1 week + a shadow-validation cycle. Recommendation: do Phase 1 first.