Skip to content

Methodology

This page explains how igcp-aforro reproduces, step by step, the remuneration calculation for Série A (legacy; perpetual; per-unit face value), Série B (closed to new subscriptions; no contractual maturity date), Série C (closed), Série D, Série E, and Série F Aforro certificates published by IGCP — Agência de Gestão da Tesouraria e da Dívida Pública. Each section points to the official technical sheets and identifies the code file where the rule is implemented. Research notes: Série C — pesquisa e parâmetros (Portuguese), Série A — pesquisa e parâmetros (Portuguese).

ParameterSérie FSéries D/ESérie CSérie BImplementation
Maturity15 years10 years10 yearsNo maturity date (perpetual until redemption)SeriesMetadata.maturityYears (null for A and B) in src/core/series.ts
Minimum subscription100 units (1 unit = EUR 1)100 units100 units100 unitsSeriesMetadata.minUnits
Maximum subscription100,000 units250,000 units250,000 units (post-230-A/2009)250,000 unitsSeriesMetadata.maxUnits
Subscription windowFrom 1 June 2023Série D: 1 February 2015 to 31 October 2017; Série E: 1 November 2017 to 1 June 202326 January 2008 to 31 January 20151 July 1986 to 25 January 2008SeriesMetadata.subscriptionStartDate / subscriptionEndDate
CapitalizationQuarterly, automaticQuarterly, automaticQuarterly, automaticQuarterly, automaticSeriesMetadata.capitalizationFrequency
Unit quote precision5 decimal places5 decimal places5 decimal places5 decimal placesSeriesMetadata.unitQuoteDecimals
IRS withholding28% on interest at capitalization28% on interest at capitalization28% default (aligned with other series)28% defaultSeriesMetadata.defaultIrsRate, overridable with simulate({ irsRate })

For Séries C, D, E, and F, the base rate for each month M is built from 3-month Euribor according to the technical sheet. The common steps are:

  1. Determine the antepenultimate TARGET2 business day of month M-1; this is the fixingDate.
  2. Take the sequence of the 10 TARGET2 business days ending on, and including, fixingDate.
  3. Calculate the simple arithmetic mean of those 10 3-month Euribor fixings.
  4. Round the mean to 3 decimal places using half-even rounding.

The final step differs by series:

  • Série F: the rounded result is clamped to [0%, 2.5%].
  • Séries D and E: add a fixed +1 percentage point spread (E3 + 1%) to the rounded mean, then clamp the final value to [0%, 3.5%]. The order matters: rounding is applied to the mean before adding the spread, and the clamp is applied after.
  • Série C: apply 0.85 × E3 + k, where E3 is the already 3dp-rounded mean; k is −0.25 for published months through February 2009 and +0.25 from March 2009 onward (Portarias n.º 73-A/2008 and 230-A/2009). The scaled value is rounded again to 3dp before a floor clamp at 0% (no upper cap like D/E/F).

Série B: the monthly base rate is 0.60 × TBA (Portaria n.º 73-B/2008). TBA follows Decreto-Lei n.º 11/1999: simple 20-business-day moving averages of 3M and 12M Euribor (or Lisbor for 1999-02…2002-03), anchored on the same fixingDate (antepenultimate TARGET2 business day of M-1). Composition, windowing, and rounding are in src/core/tba.ts; the shared Séries A/B branch is in src/core/baseRate.ts. For months ≥ 2002-04, fixings come from src/data/euribor3m.json and src/data/euribor12m.json (Deutsche Bundesbank / EMMI). For 1999-02 … 2002-03, bundled Lisbor daily series are used. For 1986-07 … 1999-01, monthly TBA comes from src/data/tba-history.json.

Série A uses the same 0.60 × TBA formula as Série B from 1986-07 onward. For 1961-01 … 1986-06, the monthly base rate is read directly from src/data/serie-a-admin-rates.json (without the 0.60× factor). See Série A — pesquisa e parâmetros (Portuguese) for the full waterfall.

Nominal principal (Série A): one certificate unit represents EUR 0.34916 (SeriesMetadata.unitFaceValueEur). Booked net value is round(units × 0.34916 × quote, 2); Séries B–F keep EUR 1 per unit.

Séries C–F logic in baseRate.ts uses the TARGET2 calendar in src/core/calendar.ts. Séries D/E/F parameterize the additive spread in SeriesMetadata.baseRateSpreadPct (Série F: '0', Séries D/E: '1'). Série C uses baseRateEuriborMultiplierPct ('0.85') and the monthly schedule baseRatePostMeanOffsets.

Correctness against IGCP-published values is covered by the golden tests in tests/baseRate.test.ts.

A permanence premium is added to the base rate, indexed to the contract year elapsed since the subscription date (based on anniversaries). The tiers differ by series.

Série F (defined in SERIE_F_PREMIUM_TIERS):

Contract yearsPremium added to base rate
10.00%
2 to 5+0.25%
6 to 9+0.50%
10 to 11+1.00%
12 to 13+1.50%
14 to 15+1.75%

Séries D and E (defined in SERIE_D_PREMIUM_TIERS / SERIE_E_PREMIUM_TIERS):

Contract yearsPremium added to base rate
10.00%
2 to 5+0.50%
6 to 10+1.00%

Série C — two tables: quarters starting before 2009-03-01 use the 73-A/2008 premiums (premiumTiersLegacy); on or after that date, the 230-A/2009 table (premiumTiers).

RegimeYears 2–3Years 4–7Year 8Year 9Year 10
73-A/2008+0.25% / +0.50%+0.75%+1.00%+1.50%+2.50%
230-A/2009+0.50% / +0.75%+1.00%+1.25%+1.50%+2.50%

(Year 1 = 0.00% in both.)

Série B (SERIE_B_PREMIUM_TIERS, aligned with the legal ladder cited by IGCP — Portaria n.º 1219/1991 and successors). Série A reuses the same ladder in code.

Contract years (A and B)Premium added to base rate
10.00%
2+0.25%
3+0.50%
4+0.75%
5+1.00%
6+1.25%
7+1.50%
8+1.75%
9 onward+2.00%

In every series, year 1 is represented explicitly as a zero-premium tier so every schedule row can always carry a non-null premiumTier. The tiers are defined in src/core/series.ts.

premiumTierForYear(series, contractYear, quarterStartDate?) resolves the applicable tier (the third argument selects legacy vs modern on Série C); getRateForCohort() composes the annual rate (base + premium).

Quarterly capitalization and IRS withholding

Section titled “Quarterly capitalization and IRS withholding”

At each quarter end Q, simulate() keeps the net position as a unit quote, not as a high-precision EUR balance:

  1. Resolve the annual rate for the quarter, based on quarterStartDate (the base-rate reference month) and the cohort’s contract age at that start date.
  2. Calculate the quarterly rate as annual_rate / 4.
  3. Calculate gross interest per unit as unit_quote x quarterly_rate.
  4. Apply IRS withholding to the per-unit interest to obtain net interest per unit.
  5. The new quote is unit_quote + net_interest_per_unit, rounded to 5 decimal places using half-even rounding.

The loop is implemented in src/core/calculator.ts and uses big.js (alias Big) for exact decimal arithmetic. The initial quote is 1.00000; after each completed capitalization, currentUnitQuote carries the rounded net quote. The reported net value is always:

currentValueNet = round(units x unitFaceValueEur x currentUnitQuote, 2)

This matches the cent-level values shown by aforro.net for certificates in a portfolio, regardless of the number of units.

In parallel, gross interest and IRS values are booked in real euros at holding level:

interestGross = round(units x unitFaceValueEur x previous_quote x quarterly_rate, 2)
irsWithheld = round(interestGross x irsRate, 2)
interestNet = interestGross - irsWithheld

This reflects effective withholding in euros and keeps totalInterestGross, totalIrsWithheld, and totalInterestNet reconcilable with the schedule.

Each quarter starts on the subscription day shifted by multiples of 3 months. When the target day does not exist in the destination month (for example, subscription on 31 January -> next quarter on 30 April), roll-forward to the first day of the following month is applied according to the technical sheet. shiftMonths() in src/core/dateMath.ts implements this behavior and is used by both the calculator and the rates.json generator.

For annual totals of gross interest and IRS withheld (for example when filling Modelo 3 / Anexo E), the library exposes rollupTaxYears(), getTaxYearRollup(), and portfolio variants. Each capitalized schedule row is attributed to the calendar year of quarterEndDate (UTC, year taken from the ISO YYYY-MM-DD string).

This matches when interest and withholding are booked at quarterly capitalization (see above). accruedSinceLastCapitalization (mid-quarter accrued interest, no withholding) is not included. Totals are cent-exact sums of each row’s interestGross, irsWithheld, and interestNet; they do not map to specific Anexo E box numbers (those change yearly and require legal review).

When asOfDate falls strictly inside an open quarter, simulate() separately reports accrued but not yet capitalized interest in accruedSinceLastCapitalization. It is calculated pro rata in calendar days over the theoretical quarterly interest:

accrued = balance x quarterly_rate x elapsed_days / total_days

where balance is units x unitFaceValueEur x currentUnitQuote, quantized to cents with banker’s rounding. This number is a library convention, not a value published by IGCP: IRS withholding is not applied (it only occurs at capitalization). Consumers estimating a “redemption value today” should subtract accrued x IRS themselves.

simulateRedemption() in src/core/redemption.ts computes the payable value of an early redemption (full or partial) while delegating quote math to simulate({ asOfDate: redemptionDate }) so quote cadence remains identical.

Applied rules:

  • Minimum holding period: redemption is only allowed from subscriptionDate + 3 months onward (SeriesMetadata.minimumHoldingMonths, currently 3 for all supported series).
  • Maturity boundary: for series with a finite maturityYears, redemptionDate on or after maturity is not early redemption and is rejected; use simulate() for matured payouts. Séries A and B are perpetual (maturityYears: null): there is no maturity cutoff in simulateRedemption().
  • Payable redemption value: redemptionValue = round(unitsToRedeem x unitFaceValueEur x currentUnitQuote, 2), where currentUnitQuote is the booked quote after the last completed capitalization at redemption date.
  • Accrued between capitalizations: the redeemed slice of accruedSinceLastCapitalization is reported as forfeitedAccruedGross and is not paid at redemption (interest is only paid on capitalization dates).
  • Partial redemption guardrails: unitsToRedeem must be in [1, units], and the residual position must be either 0 (full redemption) or at least the series minUnits.

Reconciliation note: for partial redemptions, round(unitsToRedeem x quote, 2) + round(remainingUnits x quote, 2) can differ from round(units x quote, 2) by one cent due to independent rounding.

simulatePortfolio() models a portfolio as a list of independent subscription cohorts. Each top-up is represented as a new row, even when it has the same series and the same subscription date as an existing row.

Applied rules:

  • Each cohort is simulated through simulate() using the same portfolio-level asOfDate.
  • The units cap is enforced per series by summing all rows in that series (assumption: one Conta Aforro per series).
  • Portfolio aggregation sums already-cent-quantized cohort fields (currentValueNet, totalInterestNet, totalIrsWithheld, and related totals).

Because totals are built from direct sums of quantized cohort values, every PortfolioResult.total* field reconciles exactly with the corresponding sum over PortfolioResult.cohorts[].

simulate() validates inputs with Zod, reading limits from the selected series’ SeriesMetadata, before any calculation. It throws when:

  • subscriptionDate falls outside the series’ subscription window:
    • Série A: between 1960-01-01 (fixing guard) and 1986-06-30 (closed to new subscriptions);
    • Série B: between 1986-07-01 and 2008-01-25 (closed to new subscriptions);
    • Série F: strictly from 2023-06-01;
    • Série D: between 2015-02-01 and 2017-10-31 (closed to new subscriptions);
    • Série E: between 2017-11-01 and 2023-06-01 (closed to new subscriptions);
  • units falls outside the series’ [minUnits, maxUnits] range:
    • Série F: [100, 100000];
    • Séries A, B, D, and E: [100, 250000];
  • asOfDate < subscriptionDate.

For series with finite maturity, after subscriptionDate + maturityYears (15 years for Série F, 10 years for Séries C, D, and E), the loop stops at maturity and the result returns matured: true with maturityDate populated. Séries A and B stay active: matured remains false and maturityDate is null (with a safety cap on simulated quarters to bound work).