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.
Real value diversity we measured in one column (fee_amount)
| Pattern | Real examples from the data | Problem |
|---|---|---|
| Plain dollar | $0.50, $1,000, $500 | thousands 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 share | unit + basis buried in prose |
| Percentage w/ basis | 0.15% of Dollar Value, 0.025% to 0.035% ADV | percentage vs flat mixed in one field; ranges |
| Recurring | $0.15/month, $0/month | recurrence buried in string |
| Non-numeric | varies (202×), No Charge, N/A | no 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.
| Field | Type | Rule / vocabulary |
|---|---|---|
fee_id | uuid | stable per atomic fee |
extraction_id / version_id | uuid | provenance to the delivered version |
source_chunk_index | int NOT NULL | every fee MUST map to a source chunk — kills fabricated rows |
fee_category | enum | fees_and_rebates · market_data · port_and_services · legal_regulatory · participant · membership |
fee_action | enum | add · remove · route · cross · flat · na |
direction | enum | charge · rebate · credit — the single source of sign truth |
amount_value | decimal(18,8) | signed; rebate/credit ⇒ negative. NULL only if amount_type≠fixed |
amount_type | enum | fixed · percentage · formula · tiered · varies · free |
amount_unit | enum | per_contract · per_share · per_trade · percent · flat · per_month · per_port · per_user |
amount_basis | enum / null | dollar_value · adv · executed_equivalent_share · notional · null |
currency | char(3) | default USD |
participant | enum | customer · professional_customer · market_maker · away_market_maker · firm · broker_dealer · all — no "Other" (see §3) |
symbol_class | enum | penny · non_penny · all · specific |
symbol | text | controlled where possible (SPY/QQQ/IWM); free otherwise |
tier_metric | enum / null | monthly_volume · adv_pct · contracts · users · performance |
tier_lower / tier_upper | numeric / null | structured bounds — replaces "0-24,999 contracts" |
effective_date | date / null | ISO-8601 |
fee_amount_raw | text | original string, kept verbatim for audit |
correctness / completeness | int 0–100 | the 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:
- Sign: parentheses or
fee_charge_type/category = rebate/credit ⇒direction=rebate,amount_value < 0. This makes the sign explicit instead of relying on punctuation. - Strip & canonicalize: remove
$, spaces, thousands commas; normalize.00040→0.0004. - 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. - Ranges (
0.025% to 0.035%) ⇒amount_type=tiered; expand into rows or store bounds. - Non-numeric (
varies,No Charge):No Charge/Free/$0⇒amount_value=0, amount_type=fixed.varies⇒amount_type=variesand a requiredformula/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_regulatoryparticipantmembershipamount_type
fixedpercentageformulatieredvariesfreeamount_unit
per_contractper_shareper_tradepercentflatper_monthper_portper_user4 · Validation & anomaly-prevention rules
These run as CHECK constraints + a pre-delivery validator. Each maps to a real anomaly the scoring batch found.
| # | Rule | Anomaly it prevents (seen in batch) |
|---|---|---|
| V1 | direction='rebate' ⇒ amount_value ≤ 0 and vice-versa | Cboe BZX/EDGX rebates stored positive (G=116–127, 157–165) |
| V2 | source_chunk_index NOT NULL & must exist in that version's chunk set | Fabricated rows — Cboe BZX G=34/35, EDGX "Retail Membership" tiers, $850 cost |
| V3 | amount_type='varies' ⇒ formula/notes required | 202× bare varies with no value or reason |
| V4 | participant ∈ vocab (else review queue) | 4,520 "Other" rows; ambiguous Professional |
| V5 | amount_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 |
| V6 | No duplicate atomic key (participant×symbol×tier×action×direction) per version | double-counted rows in complex-order grids |
| V7 | Completeness reconcile: count(extracted) ≥ stated_atomic_fees per chunk | MIAX Options chunks 234/244 (0%), PHLX ch103 (30%) |
| V8 | Tier continuity: no gaps/overlaps in [tier_lower,tier_upper) | SQF/permit discount tiers partially extracted (only tier 4) |
$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:
- Green — correctness ≥ 90 & completeness ≥ 95 & 0 validation failures ⇒ auto-deliver.
- Amber — 60–90, or isolated V1/V5 flags ⇒ deliver with a flagged-rows manifest for the analyst.
- Red — any fabricated row (V2), completeness < 80 (V7), or correctness < 60 ⇒ hold, route to review before it reaches Jane Street.
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
- Add typed columns alongside existing strings (non-breaking); backfill via the amount parser over all delivered versions.
- Shadow-validate — run V1–V8 in report-only mode for one delivery cycle; tune plausibility bands + participant lookup.
- Enforce — flip CHECK constraints on; wire the confidence gate into the SFTP delivery step.
- Re-score —
fee-schedule-scoring-sftpis idempotent, so re-running after normalization refreshes scores cleanly with no double-writes.