A canonical, well-specified, cross-language (Python + TypeScript) reference implementation of two cap rules at once: no constituent too large, and no sector too large. The second rule is not a second pass of the first — the weight a sector gives up has to land somewhere, and wherever it lands it pushes some other sector toward its cap. This module runs that loop, and then asks the question the rulebooks leave out: does the order the caps were listed in change the answer?
📖 Full article (canonical): Group-Level Capping — The Fintech Builder
This repository is the runnable, production-oriented companion to that article. The article teaches the concept; this repo is the code you install and build on.
🧭 Browse all algorithms: Awesome FinTech Algorithms — the full index of the library.
🗂️ This algorithm's domain: Index and Benchmark Engineering › Weighting and Capping
📥 Just want to call it? It also ships in the fintech-algorithms npm package — see Two ways to use this.
| Catalog topic | D03-F02-A08 |
| Domain | D03 — Index and Benchmark Engineering |
| Family | D03-F02 — Weighting and Capping |
| Difficulty | 4 / 5 |
| Languages | Python, TypeScript |
- Two rules, one basket
- The excess goes by headroom, not by size
- The order the caps were listed in can change the index
- Why the loop needs a stopping rule
- Two ways to use this
- Install
- Quickstart
- Views: the analysis surface
- Input shape
- API reference
- Edge cases & limitations
- Testing
- Related algorithms
- License
weights = raw weights / their total
cap the constituents (the single-name loop)
repeat, for each group in turn:
if the group is over its cap, scale its members down to the cap
and hand the excess to the members of other groups that still have room,
in proportion to how much room each one has
until: a full round changes nothing
Neither Tech name breaches the 0.3 constituent cap, but together they breach the 0.5 sector cap:
A Tech raw 0.35 -> 0.263514
B Tech raw 0.25 -> 0.236486
C Finance raw 0.18 -> 0.209508
D Finance raw 0.12 -> 0.154426
E Health raw 0.1 -> 0.136066
Tech 0.5
Finance 0.363934
Health 0.136066
4 passes in all, sum 1
A sector can also be held down by its own members rather than by its cap, which group_report names:
Tech cap 0.5 its members can hold 0.6 ceiling 0.5 binding: group
Finance cap 0.6 its members can hold 0.6 ceiling 0.6 binding: neither
Health cap 0.6 its members can hold 0.3 ceiling 0.3 binding: neither
the caps together can hold 1.4, which is 0.4 more than the basket needs
That last line is the feasibility condition, stated exactly: the caps can be satisfied only if
sum over groups of min(group cap, members × constituent cap) is at least 1. This port checks it up
front; the reference discovered it mid-loop, after some sectors had already been scaled.
Tech was 0.569231, capped to 0.5, giving up 0.069231
C received 0.015662
D received 0.025195
E received 0.028373
its own members were scaled by 0.878378378378
C is the largest receiver and takes the least, because it is closest to the constituent cap and has the least room. That is also why the constituent cap survives the sector pass: a receiver can never take more than its own headroom.
The loop treats one sector at a time, so the one processed first gives up its excess into a basket nobody has touched yet. When only one sector breaches, that cannot matter:
6 orderings, 1 distinct result, order matters: false
When two breach at once, it does:
6 orderings, 2 distinct results, order matters: true
Finance then Tech 0.265671, 0.184329, 0.265541, 0.184459, 0.05, 0.05 (3 orderings)
Tech then Finance 0.265541, 0.184459, 0.265671, 0.184329, 0.05, 0.05 (3 orderings)
A between 0.265541 and 0.265671 spread 0.00013
B between 0.184329 and 0.184459 spread 0.00013
Two identical sectors, identical caps, and the one processed first ends lighter — by 13 units in
the fourth decimal, on the published weights. This is a property of the rule, not a rounding artefact,
and order_sensitivity runs every ordering and reports it. An index rulebook that does not state the
order in which group caps are applied is incomplete.
For the same reason this port fixes the order itself: groups are processed in the order their members
appear in items, not in the order the groupCaps mapping happens to iterate. The reference walked
the mapping — and a JavaScript object reorders keys that look like integers, so a basket with sectors
named "10" and "9" produced different weights in the two languages. Such names are refused here.
A sector that gives weight away can receive some of it back on the next round. The totals then approach the caps geometrically — a hair over, then a tenth of that, then a tenth again — without ever landing on them. Floating point rounds that residue away eventually and stops; exact arithmetic never does:
it settles in 8 passes; with a tolerance of 0 the totals approach the caps forever
So tolerance is where the loop stops, and the default is half a unit in the sixth decimal: the
point below which no published number can tell the difference. Pass a smaller one to iterate further,
or a larger one to stop sooner. With zero, this basket runs to maxIterations and is refused rather
than published half-capped.
This repo is the production home: the full implementation, the analysis surface below, and 175 tests across two languages.
The fintech-algorithms npm package
ships the same topic as one import among several hundred.
fintech-algorithms/index-and-benchmark-engineering/weighting-and-capping/group-level-capping
Python
cd python
pip install -e ".[dev]"TypeScript
cd typescript
npm install
npm run buildfrom fintech_group_cap import calculate, order_sensitivity
basket = {
"items": [{"id": "A", "group": "Tech", "weight": 0.35},
{"id": "B", "group": "Tech", "weight": 0.25},
{"id": "C", "group": "Finance", "weight": 0.18},
{"id": "D", "group": "Finance", "weight": 0.12},
{"id": "E", "group": "Health", "weight": 0.1}],
"constituentCap": 0.3,
"groupCaps": {"Tech": 0.5, "Finance": 0.6, "Health": 0.6},
}
calculate(basket)["weights"] # [0.263514, 0.236486, 0.209508, 0.154426, 0.136066]
calculate(basket)["groupWeights"] # {'Tech': 0.5, 'Finance': 0.363934, 'Health': 0.136066}
order_sensitivity(basket)["orderMatters"] # FalseTypeScript is the same call:
import { calculate, orderSensitivity } from 'fintech-group-cap';
calculate(basket).groupWeights; // { Tech: 0.5, Finance: 0.363934, Health: 0.136066 }Run the tour in either language:
cd python && python examples/quickstart.py
cd typescript && npm run exampleBoth print byte-identical output.
Per sector: its cap, what its members could hold under the constituent cap, the lower of the two
(ceiling), the starting and published weights, the change, the headroom left, the pinned members, and
bindingCap — group, constituent, or neither.
The single-name loop first, then each sector round: which sector breached, the factor its members were scaled by, the excess it gave up, the headroom available, and what each receiver took.
Runs the basket in every group order (refused above 6 groups, since that is n! runs), collapses
them into distinct outcomes, and reports orderMatters, the per-member spread, and the widest one.
theBasketIsEchoedInOrder, everyWeightIsAtMostTheConstituentCap, everyGroupIsAtMostItsCap,
theGroupTotalsMatchTheirMembers, bindingGroupsSitAtTheirCap, theWeightsSumToOne and
theWeightsAreTheOnesTheLoopProduces — all to publication precision, because a group total built
from weights rounded to six decimals can miss its cap by half a unit per member.
| Function | Returns |
|---|---|
calculate(data) / group_level_capping(data) |
{ids, weights, groupWeights, iterations, weightSum} |
validate_request(data) |
the parsed request, exact, with members, groupOrder, cappingCapacity |
cap_constituents(request) |
the single-name loop on its own |
group_cap_weights(request) |
both loops, with every pass kept |
group_report(data) |
sector by sector, with the binding cap |
capping_trace(data) |
both loops pass by pass, with receivers |
order_sensitivity(data) |
every group order, and whether it matters |
verify_group_capping(data, result?) |
{checks, failedChecks, ok, published…} |
TypeScript exports the same surface in camelCase, plus Rational, number, render, scaled,
toFraction and trim from ./exact.ts.
This port is stricter than the reference engine, on purpose.
- Groups are processed in first-appearance order in
items, not ingroupCapsorder, so the two languages cannot disagree. See the section above. - Every group must have a cap. The reference read totals only for the groups named in
groupCaps, so a member of an unlisted sector was never capped and never appeared ingroupWeights— it simply vanished from the report while still holding weight. - Sector names that JavaScript treats as array indices are refused (
"0","10","4294967294"), becausegroupWeightsis an object and such keys are listed first, in numeric order, whatever order they were inserted in."007"and"4294967295"are fine — JavaScript keeps those in insertion order. - Feasibility is checked up front, exactly, as
sum over groups of min(group cap, members × constituent cap) ≥ 1. toleranceis a stopping rule, not slack to be ignored, and defaults to half a published unit.- Weights must be positive and the caps must lie in
(0, 1]; the reference validated nothing.
Verified, not assumed. Differential against the monorepo reference on 8,000 generated baskets:
0 unexplained divergences, every one classified by name — items validation 181, index-like group
name 79, coercion 58, infeasible caps 51, non-positive weight 31, missing group cap 28, required
control keys 16, tolerance slack 7, group order 3, convergence 2, rounding rule 2. The group order
class is proven, not assumed: the port is rerun in the reference's own group order and must then
reproduce the reference's answer. An exact-rational oracle written from the article, not from the port,
reran both loops on all 2,633 port results and agreed on every one. Python vs TypeScript on 300 scenarios × 5 calls (1,088 of them
computed rather than refused): byte-identical canonical JSON (1.0 MB). That run is smaller than
the ones for the sibling repos on purpose — order_sensitivity re-runs the whole loop once per group
ordering in exact fractions, and on a basket that never quite settles those denominators grow large
enough that bigint division dominates the clock. Examples byte-identical and ASCII-only;
tsc --strict checked in an isolated staging directory.
cd python && pytest # 116 tests
cd typescript && npm test # 59 tests- Iterative Cap Redistribution — the single-name loop this one is built on top of.
- Capped Free-Float Market-Cap Index — the same capping applied to free-float market values.
- Free-Float Market-Cap Index — where the raw weights usually come from.
MIT © The Fintech Builder
{ "items": [ // non-blank ids, unique; positive weights, any scale {"id": "A", "group": "Tech", "weight": 0.35}, {"id": "C", "group": "Finance", "weight": 0.18} ], "constituentCap": 0.3, // in (0, 1]; cap x members must be at least 1 "groupCaps": {"Tech": 0.5, "Finance": 0.6}, // every group in items needs one; each in (0, 1] "tolerance": 0.0000005, // optional; default half a published unit "maxIterations": 200 // optional, default 200 }