Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fintech Group-Level Capping — Index Engineering Algorithm

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?

Python TypeScript License Tests

📖 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

Table of contents


Two rules, one basket

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.


The excess goes by headroom, not by size

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 order the caps were listed in can change the index

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.


Why the loop needs a stopping rule

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.


Two ways to use this

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

Install

Python

cd python
pip install -e ".[dev]"

TypeScript

cd typescript
npm install
npm run build

Quickstart

from 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"]   # False

TypeScript 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 example

Both print byte-identical output.


Views: the analysis surface

group_report — which cap is actually binding

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.

capping_trace — both loops, pass by pass

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.

order_sensitivity — is the published index well defined?

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.

verify_group_capping — seven checks on somebody else's weights

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.


Input shape

{
  "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
}

API reference

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.


Edge cases & limitations

This port is stricter than the reference engine, on purpose.

  • Groups are processed in first-appearance order in items, not in groupCaps order, 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 in groupWeights — it simply vanished from the report while still holding weight.
  • Sector names that JavaScript treats as array indices are refused ("0", "10", "4294967294"), because groupWeights is 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.
  • tolerance is 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.


Testing

cd python && pytest          # 116 tests
cd typescript && npm test    # 59 tests

Related algorithms


License

MIT © The Fintech Builder