Skip to main content

Ranking Policy

A Ranking Policy defines how points are awarded to participants for their tournament performance. It is attached to a tournament or event using the standard policy system under the key POLICY_TYPE_RANKING_POINTS.

import { POLICY_TYPE_RANKING_POINTS } from 'tods-competition-factory';

const policyDefinitions = {
[POLICY_TYPE_RANKING_POINTS]: {
awardProfiles: [...],
qualityWinProfiles: [...],
doublesAttribution: 'fullToEach',
requireWinForPoints: false,
requireWinFirstRound: true,
},
};

// Attach to tournament
tournamentEngine.attachPolicies({ policyDefinitions });

// Or pass directly
scaleEngine.getTournamentPoints({ policyDefinitions, level: 3 });

Policy Structure

{
awardProfiles: AwardProfile[]; // How points are awarded per draw/event
qualityWinProfiles?: QualityWinProfile[]; // Bonus for beating ranked opponents
doublesAttribution?: string; // 'fullToEach' | 'splitEven'
requireWinForPoints?: boolean; // Global: must win to earn position points
requireWinFirstRound?: boolean; // Global: R1 losers need a win for points
pointsAuthority?: PointsAuthority; // Issuing authority (ATP, WTA, ITF, …)
}

Points Authority

pointsAuthority declares which body issues the points awarded under a policy. Every PointAward emitted from the policy carries this value, so downstream consumers — most importantly federated rank lists like Tennis Europe's, which mix ATP + ITF + TE-internal points into one list — can scope and weight by source without re-joining to policy metadata.

The vocabulary is a closed enum exported from @Constants/pointsAuthorityConstants:

import {
ATP,
WTA,
ITF,
ITF_JUNIOR,
ITF_WHEELCHAIR,
TENNIS_EUROPE,
USTA,
LTA,
FFT,
DTB,
PPA,
BWF,
UTR,
UNSPECIFIED,
POINTS_AUTHORITIES,
type PointsAuthority,
} from 'tods-competition-factory';

The field is optional — policies that don't declare it produce awards with pointsAuthority: undefined, and downstream consumers (e.g. courthive-rankings) default such rows to 'UNSPECIFIED'. The published CourtHive fixtures declare:

PolicypointsAuthority
POLICY_RANKING_POINTS_ATPATP
POLICY_RANKING_POINTS_WTAWTA
POLICY_RANKING_POINTS_ITF_WTTITF
POLICY_RANKING_POINTS_ITF_JUNIORITF_JUNIOR
POLICY_RANKING_POINTS_BASIC(unset)

The vocabulary intentionally aligns with TierClassification.system so a tournament's tier-system ('ATP', 'ITF_JUNIOR', 'PPA', …) lines up with the authority of the points it awards.

Per-AwardProfile override

AwardProfile.pointsAuthority is the per-profile override that makes federated rank lists possible. When set, it stamps every award the profile matches with its own authority — overriding the policy-level pointsAuthority for that profile only. Resolution rule at award time:

award.pointsAuthority = matchedProfile.pointsAuthority ?? policy.pointsAuthority;

The Tennis Europe production rank list aggregates points from three issuing authorities into a single weekly list. With per-profile authority, the entire list is one policy:

const POLICY_RANKING_POINTS_TE_HYBRID = {
[POLICY_TYPE_RANKING_POINTS]: {
policyName: 'Tennis Europe Hybrid 2026',
pointsAuthority: TENNIS_EUROPE, // default for any profile
awardProfiles: [
{
profileName: 'TE Circuit (16U/18U)',
// matches TE-circuit events; no authority override → TENNIS_EUROPE
finishingPositionRanges: {
/* TE point values */
},
},
{
profileName: 'ITF Junior crossover',
pointsAuthority: ITF_JUNIOR, // override for ITF events
levels: [
/* ITF Jr levels */
],
finishingPositionRanges: {
/* ITF point values */
},
},
{
profileName: 'ATP crossover',
pointsAuthority: ATP, // override for ATP events
levels: [
/* ATP levels */
],
finishingPositionRanges: {
/* ATP point values */
},
},
],
},
};

Every award emitted under this policy carries the issuing authority of whichever profile matched its draw — so courthive-rankings can scope, filter, and weight by source authority directly, without splitting the rank list across three separate policies.

A working reference fixture lives at src/tests/fixtures/policies/POLICY_RANKING_POINTS_HYBRID_EXAMPLE.ts. It models the three-bucket pattern observed in Tennis Europe's production rank list (TE-circuit + ITF Junior crossover + ATP crossover). Point values in that fixture are placeholders — copy the file, calibrate the ranges against the actual federation rulebook, and adjust the scoping (levels, drawSizes, eventTypes) to match the federation's event taxonomy.

The override applies to every award shape emitted from getTournamentPoints: main awards, doubles-split individual awards (via spread), team line-points awards, and quality-win bonus awards. Quality-win bonuses inherit the matched profile's authority for the same draw (so a player who won an ITF crossover event gets ITF-stamped quality wins, even under a TE-Hybrid policy).

Why a separate field from policyName?

policyName identifies a specific published rulebook ('PIF ATP Rankings 2026'); pointsAuthority identifies the issuing body. Two policies for ATP can coexist (e.g. a 2025 and 2026 version) but both share pointsAuthority: ATP. Filtering by authority — "include every ATP award in the rolling window, regardless of which annual policy was in effect" — is impossible from policyName alone.

Authority weighting at aggregation time

The authority weight a federated rank list applies (e.g. Tennis Europe weights ITF at 1.0 and TENNIS_EUROPE at 1.0, while a USTA-internal list might weight external authorities at 0.5) is not part of the policy. It belongs to the consuming rank list and is applied at aggregation time, downstream of the factory. See the courthive-rankings AggregateArgs.authorityWeights and AggregateArgs.authorityFilter inputs.

Award Profiles

Each awardProfile defines point values for a specific scope (draw type, level, category, etc.). When computing points, the Scale Engine selects the best-matching profile using specificity scoring.

Minimal Profile

awardProfiles: [
{
finishingPositionRanges: {
1: { value: 100 },
2: { value: 75 },
4: { value: 50 },
8: { value: 25 },
},
},
];

Full Profile

{
// Identity
profileName: 'Elimination L1-3', // For debugging/audit (shown in devContext)

// Scope — determines when this profile applies
eventTypes: ['SINGLES'], // SINGLES, DOUBLES, TEAM
drawTypes: ['SINGLE_ELIMINATION', 'FEED_IN_CHAMPIONSHIP'],
drawSizes: [32, 64], // exact draw sizes
maxDrawSize: 128, // or a maximum
levels: [1, 2, 3], // tournament levels
maxLevel: 5, // or a maximum
stages: ['MAIN'], // MAIN, QUALIFYING, CONSOLATION
flights: [1], // flight numbers
maxFlightNumber: 2, // or a maximum
dateRanges: [{ startDate: '2025-01-01', endDate: '2025-12-31' }],
participationOrder: 1, // 1 = first structure entry

// Category scope
category: {
ageCategoryCodes: ['U18'],
genders: ['MALE'],
categoryNames: ['Junior'],
categoryTypes: ['AGE'],
ratingTypes: ['WTN'],
ballTypes: ['GREEN'],
wheelchairClasses: ['QUAD'],
subTypes: ['ADVANCED'],
},

// Priority override (bypasses specificity scoring)
priority: 10,

// Position points (key = Math.max(finishingPositionRange), see Finishing Positions concept)
finishingPositionRanges: {
1: { level: { 1: 3000, 2: 1650, 3: 990 } },
2: { level: { 1: 2400, 2: 1320, 3: 792 } },
4: { level: { 1: 1800, 2: 990, 3: 594 } },
8: { level: { 1: 1200, 2: 660, 3: 396 } },
16: { level: { 1: 600, 2: 330, 3: 198 } },
32: { level: { 1: 300, 2: 165, 3: 99 } },
},

// Per-win points
perWinPoints: {
level: { 1: 300, 2: 225, 3: 150 },
},
// or flat:
pointsPerWin: 60,

// Max countable matches (per participant per draw)
maxCountableMatches: 5,
// or level-keyed:
// maxCountableMatches: { level: { 3: 5, 4: 4 } },

// Bonus points (champion/finalist)
bonusPoints: [
{ finishingPositions: [1], value: { level: { 6: 50, 7: 25 } } },
{ finishingPositions: [2], value: { level: { 6: 30, 7: 15 } } },
],

// Win requirements
requireWinForPoints: false,
requireWinFirstRound: true,
}

Position Value Resolution

The keys in finishingPositionRanges are accessors — each key equals Math.max(finishingPositionRange) for the corresponding draw round. See Finishing Positions for how these values are computed from draw structures.

Values in finishingPositionRanges can be expressed in several forms:

Simple Value

{ 1: { value: 1000 } }
// or just a number:
{ 1: 1000 }

Position Level-Keyed

{ 1: { level: { 1: 3000, 2: 1650, 3: 990 } } }

The level parameter passed to getTournamentPoints selects the value.

Draw Size Threshold

{ 1: [
{ threshold: 16, value: 500 },
{ threshold: 32, value: 800 },
{ threshold: 64, value: 1000 },
] }

The highest threshold <= drawSize is used.

Flight-Specific

{ 1: { flights: { 1: 1000, 2: 500 } } }

Won/Lost Accessors

{ 4: { won: 400, lost: 200 } }

Points differ based on whether the participant won a match at that finishing position (useful for consolation draws).

Per-Win Points

Per-win points are awarded for each match won, typically as an alternative to position points (when no finishingPositionRanges key matches the accessor).

Flat Value

{
pointsPerWin: 60;
}

Per-Win Level-Keyed

{
perWinPoints: {
level: { 1: 300, 2: 225, 3: 150 }
}
}

With Participation Order

{
perWinPoints: [
{ participationOrders: [1], level: { 1: 300, 2: 225 } }, // main draw
{ participationOrders: [2], level: { 1: 100, 2: 75 } }, // consolation
];
}

Team Line Points

For team events, per-win values can vary by line position:

{
perWinPoints: {
level: {
1: { line: [300, 275, 250, 225, 200, 175], limit: 6 }
}
}
}

The line array is indexed by collectionPosition - 1. The limit property means only the first N lines earn points.

Quality Win Profiles

Quality win profiles define bonus points for defeating ranked opponents:

qualityWinProfiles: [
{
rankingScaleName: 'NATIONAL_RANKING',
rankingSnapshot: 'tournamentStart',
unrankedOpponentBehavior: 'noBonus',
includeWalkovers: false,
maxBonusPerTournament: 500,
rankingRanges: [
{ rankRange: [1, 10], value: 225 },
{ rankRange: [11, 25], value: 203 },
{ rankRange: [26, 50], value: 169 },
],
},
];

See Quality Win Points for detailed documentation.

Doubles Attribution

Controls how pair points flow to individual participant records:

{
doublesAttribution: 'fullToEach';
} // each individual gets 100%
{
doublesAttribution: 'splitEven';
} // each individual gets 50%

When not specified, points remain only on the pair record.

Specificity Scoring

When multiple profiles match a participation, the one with the most populated scope fields wins. For example:

awardProfiles: [
// Score 0: no scope constraints (catch-all)
{ finishingPositionRanges: { 1: { value: 100 } } },

// Score 3: drawTypes + levels + maxDrawSize
{ drawTypes: ['ROUND_ROBIN'], levels: [3, 4, 5], maxDrawSize: 16, perWinPoints: { level: { 3: 225 } } },
];

The Round Robin profile (score 3) wins over the catch-all (score 0) for RR draws at levels 3-5.

To force a specific profile regardless of scoring, use priority:

{ priority: 10, drawTypes: ['ROUND_ROBIN'], ... }

See Profile Selection for the complete scoring rules.

Complete Examples

Simple Club Ranking

const clubPolicy = {
[POLICY_TYPE_RANKING_POINTS]: {
awardProfiles: [
{
finishingPositionRanges: {
1: { value: 100 },
2: { value: 75 },
4: { value: 50 },
8: { value: 25 },
},
pointsPerWin: 10,
},
],
},
};

USTA-Style Multi-Profile

const ustaPolicy = {
[POLICY_TYPE_RANKING_POINTS]: {
requireWinFirstRound: true,
doublesAttribution: 'fullToEach',
awardProfiles: [
// Elimination draws L1-3
{
profileName: 'Elimination L1-3',
drawTypes: ['SINGLE_ELIMINATION', 'FEED_IN_CHAMPIONSHIP', 'COMPASS'],
levels: [1, 2, 3],
finishingPositionRanges: {
1: { level: { 1: 3000, 2: 1650, 3: 990 } },
2: { level: { 1: 2400, 2: 1320, 3: 792 } },
4: { level: { 1: 1800, 2: 990, 3: 594 } },
8: { level: { 1: 1200, 2: 660, 3: 396 } },
16: { level: { 1: 600, 2: 330, 3: 198 } },
32: { level: { 1: 300, 2: 165, 3: 99 } },
},
},
// Round Robin L3-5 (per-win only)
{
profileName: 'Round Robin L3-5',
drawTypes: ['ROUND_ROBIN'],
levels: [3, 4, 5],
maxCountableMatches: 5,
perWinPoints: { level: { 3: 225, 4: 150, 5: 75 } },
},
],
qualityWinProfiles: [
{
rankingScaleName: 'USTA_JUNIOR',
rankingSnapshot: 'tournamentStart',
unrankedOpponentBehavior: 'noBonus',
rankingRanges: [
{ rankRange: [1, 10], value: 225 },
{ rankRange: [11, 25], value: 203 },
{ rankRange: [26, 50], value: 169 },
{ rankRange: [51, 100], value: 101 },
],
},
],
},
};

FIC Draw Policy

Feed-in consolation draws produce distinct accessor values. Map all possible positions:

{
drawTypes: ['FEED_IN_CHAMPIONSHIP'],
finishingPositionRanges: {
1: { value: 1000 },
2: { value: 700 },
3: { value: 500 }, // consolation champion
4: { value: 400 },
6: { value: 300 },
8: { value: 200 },
12: { value: 150 },
16: { value: 100 },
24: { value: 75 },
32: { value: 50 },
},
}
tip

For a 32-draw FIC, the possible accessor values are 1, 2, 3, 4, 6, 8, 12, 16, 24, 32. Participants automatically receive the best position across main and consolation structures.

Aggregation Rules

When using generateRankingList across multiple tournaments, define aggregation behavior:

aggregationRules: {
countingBuckets: [
{ bucketName: 'Singles', eventTypes: ['SINGLES'],
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
bestOfCount: 6 },
{ bucketName: 'Doubles', eventTypes: ['DOUBLES'],
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
bestOfCount: 2 },
{ bucketName: 'Quality Wins',
pointComponents: ['qualityWinPoints'],
bestOfCount: 0 },
],
rollingPeriodDays: 365,
minCountableResults: 3,
tiebreakCriteria: ['highestSingleResult', 'mostWins'],
}

See Multi-Tournament Aggregation for detailed documentation.

Qualifying Profiles

Qualifying stages use a normalized position convention that differs from main draw positions. See Qualifying Position Normalization for how the pipeline transforms raw qualifying positions.

When writing qualifying profiles, use the stages: ['QUALIFYING'] scope and define finishingPositionRanges with normalized keys:

{
profileName: 'Qualifying Singles',
stages: ['QUALIFYING'],
eventTypes: ['SINGLES'],
levels: [1, 2, 3, 4],
finishingPositionRanges: {
1: { level: { 1: 4, 2: 3, 3: 3, 4: 2 } }, // Qualifier (won through)
2: 1, // Final round loser
},
}
info

Qualifying profiles only produce points when the tournament has a qualifying draw structure. A main-draw-only tournament (even with a level set) will produce zero qualifying awards. This is expected behavior — see the ITF WTT policy for an example of a qualifying-only ranking system.

Tournament Level

The level parameter is a numeric tier (1, 2, 3, ...) that selects point values from level-keyed profiles. Higher-tier tournaments (lower level numbers) typically award more points.

Level is not the same as tournamentLevel in the TODS schema (which describes geographic scope: CLUB, NATIONAL, INTERNATIONAL, etc.). The ranking policy level is a tier within a specific ranking system:

SystemLevel examples
ATP1 = Grand Slam, 2 = ATP Finals, 8 = ATP 250, 15 = ITF M15
WTA1 = Grand Slam, 2 = WTA Finals, 5 = WTA 250, 11 = ITF W15
ITF WTT1 = $25K+H, 2 = $25K, 3 = $15K+H, 4 = $15K
ITF Junior1 = Grand Slam, 2 = J500, 5 = J100, 9 = J30
USTA Junior1 = National Championships, 7 = Intermediate

Policies that require a level will produce no awards when called without one (all profiles specify levels or maxLevel, so no profile matches). The Basic policy has no level-keyed values and produces points regardless of whether a level is passed.

Built-in Policy Fixtures

The factory ships with five ranking policy fixtures covering major professional, junior, and basic systems. Import them from fixtures.policies:

import { fixtures } from 'tods-competition-factory';

const {
POLICY_RANKING_POINTS_BASIC,
POLICY_RANKING_POINTS_ATP,
POLICY_RANKING_POINTS_WTA,
POLICY_RANKING_POINTS_ITF_WTT,
POLICY_RANKING_POINTS_ITF_JUNIOR,
} = fixtures.policies;

// Basic policy — no level needed
const basic = scaleEngine.getEventRankingPoints({
policyDefinitions: POLICY_RANKING_POINTS_BASIC,
eventId: 'event-abc',
});

// Level-requiring policies
const atp = scaleEngine.getEventRankingPoints({
policyDefinitions: POLICY_RANKING_POINTS_ATP,
eventId: 'event-abc',
level: 1,
});
FixtureLevelsPeriodBest-ofNotes
POLICY_RANKING_POINTS_BASICNone (level-independent)Simple position-based points, no level required
POLICY_RANKING_POINTS_ATP15 (Grand Slam → ITF M15)52 weeksSingles: 19, Doubles: 18Mandatory counting rules, qualifying points
POLICY_RANKING_POINTS_WTA11 (Grand Slam → ITF W15)52 weeksSingles: 18, Doubles: 12Draw size threshold arrays
POLICY_RANKING_POINTS_ITF_WTT4 ($25K+H → $15K)52 weeks14Qualifying-only system (post-2020, no main draw points)
POLICY_RANKING_POINTS_ITF_JUNIOR9 (Grand Slam → J30)ITF Junior Circuit with qualifying and consolation

These fixtures can be used as-is for preview/backoffice ranking point calculations, or as starting points for custom policies.

Federation policies served via CFS

As of factory 4.0.0, federation-specific ranking policies (USTA Junior 2025/2026, Tennis Europe, LTA, Tennis Canada, Tennis Australia, ČTS) are no longer bundled. They live in CFS-served storage and reach the embedded factory engine via the policyRegistry at runtime:

import { policyRegistry, scaleEngine } from 'tods-competition-factory';

// Consumer (CFS, courthive-rankings, etc.) registers at boot, usually
// from a GET /policies/catalog response:
policyRegistry.register({
policyType: 'rankingPoints',
name: 'USTA_JUNIOR_2026',
version: '2026.01',
definition: /* fetched from CFS */,
});

// Engine resolves by name when policyDefinitions isn't passed:
scaleEngine.getTournamentPoints({
tournamentRecord,
policyName: 'USTA_JUNIOR_2026',
level: 1,
});

See POLICY_DELIVERY in the orchestration repo for the full architecture and the per-consumer migration paths (TMX, courthive-rankings, courthive-ingest).