igcp-aforro
v2026.527.0
Published
TypeScript library and CLI for simulating Portuguese IGCP Aforro Séries A, B, C, D, E, and F Treasury Certificates: deterministic quarterly compounding with IGCP-aligned monthly base rates (Série A/B hybrid TBA path; Euribor 3M for Séries C–F) and permane
Maintainers
Readme
igcp-aforro
Deterministic, decimal-safe TypeScript library and CLI for simulating Portuguese IGCP Aforro Séries A, B, C, D, E, and F Treasury Certificates. Drop-in for JS/TS apps; ships with a CLI and a static rates.json artifact for non-JS consumers.
About
Certificados de Aforro are Portuguese state-issued retail savings instruments. This library covers Série A (legacy perpetual certificates subscribed through 30 Jun 1986; EUR 0.34916 nominal per certificate unit; historical base-rate waterfall — see Série A research), Série B (subscriptions from 1 Jul 1986 to 25 Jan 2008, no contractual maturity; base rate 0.60×TBA from 20-day Euribor 3M and 12M moving averages — see methodology), Série C (subscriptions from 26 Jan 2008 to 31 Jan 2015, 10-year maturity; base rate 0.85×E3 ± 0.25% on the rounded Euribor mean, with a March 2009 formula flip — see docs), Série D (subscriptions open from 1 Feb 2015 to 31 Oct 2017, 10-year maturity), Série E (subscriptions open from 1 Nov 2017 to 1 Jun 2023, 10-year maturity), and Série F (subscriptions open from 1 Jun 2023 onwards, 15-year maturity). Their remuneration is the sum of:
- a monthly base rate: for Séries C, D, E, and F, from the 10-business-day average of Euribor 3M struck on the antepenultimate TARGET2 business day of the previous month, then rounded to 3 decimals (banker's). Série F clamps the result to
[0%, 2.5%]; Séries D and E add a+1ppspread to the rounded mean and clamp to[0%, 3.5%]; Série C scales the rounded mean by0.85and adds−0.25or+0.25percentage points depending on the calendar month (Portarias 73-A/2008 and 230-A/2009), then clamps at0%minimum. Séries A and B use0.60×TBA(Portaria 73-B/2008) with TBA per DL 11/1999; Série A months before Jul 1986 read bundled administrative monthly rates instead, and intermediate eras use archived TBA / Lisbor feeds as documented in the methodology andserie-a-research.md; - a permanence-premium tier that depends on how many contract years have elapsed since subscription (different tier tables per series);
- with quarterly capitalization and 28% IRS withholding applied at each capitalization.
This package reproduces that math end-to-end, with all monetary fields returned as decimal strings (via big.js with banker's rounding) so results survive JSON.stringify and cross-language boundaries without floating-point drift.
igcp-aforro is not affiliated with IGCP and does not constitute financial advice. See Methodology and legal notice.
Features
- Pure calculator — no network, no state, no globals. Euribor 3M and 12M datasets are bundled (12M is required for Série A/B TBA in the Euribor era); Lisbor daily expansion and TBA history JSON cover earlier A/B windows.
- Decimal-safe — every money/rate field is a
big.js-quantized decimal string, banker's-rounded at each cent. - aforro.net parity — booked values are derived from the same per-unit quote cadence aforro.net displays: quote rounded to 5 decimals each quarter, then
round(units × unitFaceValueEur × quote, 2)(Série A:unitFaceValueEur = 0.34916; other series:1). - Validated inputs — Zod-checked at the public boundary; the library throws on out-of-window subscriptions, invalid units, or impossible as-of dates.
- Cohort-aware rate lookup — resolve the annual rate that applies to a given subscription on a given quarter, with the base + premium components surfaced for auditability.
- CLI included —
aforro simulate | redeem | portfolio | current | rates | cohortwith stable--jsonoutput for scripting. - Static
rates.json— every monthly base rate and every cohort × quarter annual rate for Séries A, B, C, D, E, and F, precomputed and published with the docs site for Python / Java / Excel users. - Golden-tested — Série F monthly rates from IGCP press releases; Série B base rates cross-checked against IGCP-published tables; Série A waterfall tiers locked in
tests/baseRate.test.ts; Série C monthly rates locked tocomputeBaseRate()on the bundled Bundesbank Euribor series (aligned to the Diário da República formulas); Séries D and E validated against the technical sheetE3+1%behaviour. - TypeScript-first — full
.d.tstypings, ESM + CJS dual bundles, Node ≥ 20.
Installation
As a library:
pnpm add igcp-aforro
# or
npm install igcp-aforro
# or
yarn add igcp-aforroAs a global CLI:
pnpm add -g igcp-aforro
aforro --helpigcp-aforro ships ESM and CJS bundles plus full .d.ts typings.
Quickstart
Library
import { simulate, Series } from 'igcp-aforro';
const result = simulate({
series: Series.F,
subscriptionDate: '2024-03-15',
units: 1000,
asOfDate: '2026-04-19',
includeSchedule: true,
});
console.log(result.currentValueNet); // e.g. "1078.42"
console.log(result.currentUnitQuote); // e.g. "1.07842"
console.log(result.totalInterestNet); // e.g. "78.42"
console.log(result.matured); // false
console.log(result.schedule?.length); // 8 quarters since subscriptionSimulate a portfolio of cohorts
import { simulatePortfolio, Series } from 'igcp-aforro';
const portfolio = simulatePortfolio({
asOfDate: '2026-04-19',
subscriptions: [
{ series: Series.F, subscriptionDate: '2024-03-15', units: 1000 },
{ series: Series.E, subscriptionDate: '2018-01-15', units: 2500 },
{ series: Series.D, subscriptionDate: '2017-10-01', units: 1500 },
],
});
console.log(portfolio.totalValueNet);
console.log(portfolio.totalInterestNet);
console.log(portfolio.bySeries);All money and rate fields come back as decimal strings (e.g. "1078.42", "0.02750"). Feed them into Big (or your own decimal library) on the consumer side; never coerce them with Number() if you care about precision.
The throwing APIs (simulate, simulatePortfolio, simulateRedemption, getCurrentRate, getRateForCohort, getRateTable) use Zod .parse() and propagate domain errors as exceptions. If you prefer a single discriminated result (for example to map validation to HTTP 400 without a ZodError catch block), use the safe* variants — safeSimulate, safeSimulatePortfolio, safeSimulateRedemption, safeGetCurrentRate, safeGetRateForCohort, safeGetRateTable — which return { ok: true, value } or { ok: false, kind: 'validation' | 'runtime', ... } (SafeResult).
CLI
aforro simulate --subscribed 2024-03-15 --units 1000 --schedule
aforro redeem --subscribed 2024-03-15 --units 1000 --redeem-on 2026-04-19
aforro portfolio --input ./portfolio.json --json
aforro portfolio --cohort F,2024-03-15,1000 --cohort E,2018-01-15,2500 --as-of 2026-04-19 --json
aforro current
aforro rates --from 2023-06 --to 2026-04
aforro rates --from 2023-06 --to 2026-04 --csv > rates.csv
aforro cohort --subscribed 2024-03 --as-of 2026-04
aforro cohort --subscribed 2024-03 --as-of 2026-04 --csv > cohort.csvFor portfolio, read JSON from a file with --input ./file.json, or from stdin with --input=- (the = is required; bare --input - is not parsed as a path).
Every command accepts --json for machine-readable output. The rates and cohort commands also accept --csv (comma-separated, header row, RFC 4180 field quoting); do not combine --csv with --json.
aforro simulate --subscribed 2024-03-15 --units 1000 --json | jq .currentValueNet
aforro portfolio --input=- --json < portfolio.json | jq .totalValueNetUsage examples
Look up rates without running a full simulation
import { getCurrentRate, getRateForCohort, getRateTable } from 'igcp-aforro';
getCurrentRate({ series: 'F' });
// → { series: 'F', month: '2026-04', fixingDate: '2026-03-27', basePct: '2.500' }
getRateForCohort({
series: 'F',
subscriptionDate: '2024-03-15',
asOfDate: '2026-04-19',
});
// → {
// series: 'F',
// subscriptionDate: '2024-03-15',
// asOfDate: '2026-04-19',
// quarterStartDate: '2026-03-15',
// quarterEndDate: '2026-06-15',
// quarterIndex: 8,
// yearsSinceSubscription: 2,
// baseRatePct: '2.500',
// premiumTier: { fromYear: 2, toYear: 5, ratePct: '0.25' },
// annualRatePct: '2.750',
// }
getRateTable({ series: 'F', fromMonth: '2023-06', toMonth: '2026-04' });
// → MonthlyBaseRate[]Inspect the per-quarter capitalization schedule
import { simulate, Series } from 'igcp-aforro';
const { schedule } = simulate({
series: Series.F,
subscriptionDate: '2024-03-15',
units: 1000,
asOfDate: '2026-04-19',
includeSchedule: true,
});
for (const row of schedule ?? []) {
console.log(
row.quarterEndDate,
row.annualRate, // "0.02750"
row.interestGross, // "6.88"
row.irsWithheld, // "1.93"
row.interestNet, // "4.95"
row.balanceAfter, // "1004.95"
row.unitQuoteAfter, // "1.00495"
row.premiumTier.ratePct // "0.25"
);
}Override the IRS withholding rate
The default is the 28% Portuguese personal-income-tax rate on interest. Override it for non-resident scenarios or sensitivity analysis:
simulate({
series: 'F',
subscriptionDate: '2024-03-15',
units: 1000,
irsRate: 0.10,
});Project an "if I redeemed today" value
accruedSinceLastCapitalization is informational (gross accrued mid-quarter) and is not paid by IGCP on an early redemption date. For payable value, use simulateRedemption():
import { simulateRedemption } from 'igcp-aforro';
const redemption = simulateRedemption({
series: 'F',
subscriptionDate: '2024-03-15',
units: 1000,
redemptionDate: '2026-04-19',
});
console.log(redemption.redemptionValue); // payable amount at redemption date
console.log(redemption.forfeitedAccruedGross); // accrued slice forfeited on redemptionIf you still need an accrued-based projection from simulate(), subtract IRS manually:
import Big from 'big.js';
import { simulate } from 'igcp-aforro';
const r = simulate({
series: 'F',
subscriptionDate: '2024-03-15',
units: 1000,
asOfDate: '2026-04-19',
});
const accruedNet = Big(r.accruedSinceLastCapitalization)
.times(Big(1).minus(r.irsRate));
const projectedNet = Big(r.currentValueNet).plus(accruedNet);API reference
import {
simulate,
simulatePortfolio,
simulateRedemption,
getCurrentRate,
getRateForCohort,
getRateTable,
Series,
getSeries,
listSeries,
VERSION,
} from 'igcp-aforro';
import type {
SeriesCode,
SeriesMetadata,
PremiumTier,
RateEntry,
ScheduleRow,
SimulateInput,
PortfolioSubscription,
SimulatePortfolioInput,
PortfolioSeriesBreakdown,
PortfolioResult,
SimulateResult,
RedemptionInput,
RedemptionResult,
CohortRateInput,
CohortRateResult,
CurrentRateInput,
RateTableInput,
MonthlyBaseRate,
IsoDate,
IsoMonth,
} from 'igcp-aforro';| Export | Kind | What it does |
| --- | --- | --- |
| simulate(input) | function | Quarterly-compounding simulator. Returns SimulateResult (optionally with schedule). |
| getCurrentRate(input?) | function | IGCP-published monthly base rate for the current (or given) month, plus its fixingDate. |
| getRateForCohort(input) | function | Composite annual rate for a cohort × quarter, with base + premium components surfaced. |
| getRateTable(input) | function | Monthly base rates between fromMonth and toMonth (inclusive). |
| Series | enum-like | Series.D, Series.E, Series.F — the series code constants. |
| getSeries(code) | function | Returns the static SeriesMetadata for a series. |
| listSeries() | function | Returns the list of series codes the library supports. |
| VERSION | string | Library CalVer (YYYY.MMDD.PATCH). |
Full type signatures and TSDoc are generated into the docs site at https://igcp-aforro.primor.me/api/.
HTTP API
A hosted read-only JSON API runs the same safe* entry points over HTTPS (no npm install). Use it when you need parameterized simulations from Python, Excel, or curl; use rates.json when you only need precomputed rate tables.
curl -sS -X POST 'https://api.igcp-aforro.primor.me/v1/simulate' \
-H 'content-type: application/json' \
-d '{
"series": "F",
"subscriptionDate": "2024-03-15",
"units": 1000,
"includeSchedule": true
}'Responses use { "ok": true, "value": … } or { "ok": false, "kind": "validation" | "runtime", … } (HTTP 400 / 422). Every response includes X-IGCP-Aforro-Disclaimer. For AI assistants, the monorepo ships an stdio MCP server (igcp-aforro-mcp); see CONTRIBUTING.
Static rates.json for non-JS users
Python, Java, Excel, and spreadsheet users can skip the npm package entirely and consume a precomputed JSON snapshot of every monthly base rate and every cohort-anchored annual rate.
- Latest: https://igcp-aforro.primor.me/rates.json
- Per-release snapshot:
https://igcp-aforro.primor.me/v/<calver>/rates.json(e.g.v/2026.420.0/rates.json)
The file is regenerated after every release and after every Euribor / IGCP base-rate refresh PR is merged.
Top-level shape:
{
"schemaVersion": 1,
"generatedAt": "2026-04-20T08:00:00Z",
"libraryVersion": "2026.420.0",
"euriborSourceMeta": {
"lastRefreshedAt": "2026-04-19T07:42:11Z",
"source": "Deutsche Bundesbank time-series API",
"sourceUrl": "https://api.statistiken.bundesbank.de/rest/download/BBIG1/...",
"seriesId": "BBIG1.D.D0.EUR.MMKT.EURIBOR.M03.BID._Z"
},
"series": {
"D": { "metadata": { "...": "..." }, "monthlyBaseRates": [], "cohortRates": [] },
"E": { "metadata": { "...": "..." }, "monthlyBaseRates": [], "cohortRates": [] },
"F": {
"metadata": { "...": "..." },
"monthlyBaseRates": [
{ "month": "2024-03", "fixingDate": "2024-02-27", "basePct": "3.892" }
],
"cohortRates": [
{
"subscribed": "2024-03",
"subscriptionDate": "2024-03-01",
"quarterIndex": 8,
"quarterStartDate": "2026-03-01",
"quarterEndDate": "2026-06-01",
"yearsSinceSubscription": 2,
"basePct": "2.500",
"premiumTierYearsRange": "2-5",
"premiumPct": "0.25",
"annualRatePct": "2.750"
}
]
}
}
}Minimal Python compounder using only rates.json:
import json, urllib.request
from decimal import Decimal, ROUND_HALF_EVEN
data = json.load(urllib.request.urlopen('https://igcp-aforro.primor.me/rates.json'))
rows = [r for r in data['series']['F']['cohortRates'] if r['subscribed'] == '2024-03']
units = Decimal('1000')
unit_quote = Decimal('1')
irs = Decimal('0.28')
for r in rows:
annual = Decimal(r['annualRatePct']) / Decimal('100')
quarterly = annual / 4
gross = (units * unit_quote * quarterly).quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
withheld = (gross * irs).quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
net_per_unit = unit_quote * quarterly * (Decimal('1') - irs)
unit_quote = (unit_quote + net_per_unit).quantize(Decimal('0.00001'), rounding=ROUND_HALF_EVEN)
print((units * unit_quote).quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN))The full schema, day-of-month caveat, and field-by-field documentation live at https://igcp-aforro.primor.me/rates-json/.
Methodology and legal notice
Methodology
The library reproduces the IGCP technical sheets for Certificados de Aforro Série D (Portaria n.º 17-B/2015, closed by Portaria n.º 329-A/2017), Série E (Portaria n.º 329-A/2017, closed by Portaria n.º 149-A/2023), and Série F (Portaria n.º 149-A/2023). In summary:
- The monthly base rate for month
Mis the arithmetic mean of the Euribor 3M fixings over the 10 TARGET2 business days ending at the antepenultimate business day of monthM-1, rounded to 3 decimals (banker's rounding). For Série F the rounded mean is clamped to[0%, 2.5%]. For Séries D and E a+1ppspread is added to the rounded mean (E3 + 1%) and the result is clamped to[0%, 3.5%]. - The annual rate for a cohort × quarter is
baseRate(quarterStartMonth) + premium(contractYear), wherepremiumfollows the IGCP-published tier table:- Série F — year 1: 0.00%, years 2–5: +0.25%, 6–9: +0.50%, 10–11: +1.00%, 12–13: +1.50%, 14–15: +1.75%.
- Séries D and E — year 1: 0.00%, years 2–5: +0.50%, 6–10: +1.00%.
- Quarterly capitalization: the booked net state is a per-unit quote, starting at
1.00000. Each quarter computesgrossPerUnit = unitQuote × annualRate / 4, applies IRS to getnetPerUnit, then stores the next quote rounded to the series' quote precision (5decimals for Séries D, E, and F). The headline booked value iscurrentValueNet = round(units × currentUnitQuote, 2), matching aforro.net to the cent for booked certificates regardless of holding size. Gross interest and IRS withholding are booked separately in real EUR at the holding level each quarter:interestGross = round(units × previousUnitQuote × annualRate / 4, 2),irsWithheld = round(interestGross × 28%, 2), andinterestNet = interestGross − irsWithheld. Those cent-rounded quarterly rows reconcile exactly with the headlinetotalInterest*fields. - Quarter anchoring: quarters start on the subscription's day-of-month, shifted by 3-month multiples. When the day doesn't exist in the target month (e.g. subscription on 31 Jan → next quarter would land on 31 Apr), the date rolls forward to the first day of the following month per the IGCP spec.
- Validations (per series, read from
SeriesMetadata):- Série D — subscriptions in
[2015-02-01, 2017-10-31](closed to new subscriptions); units in[100, 250000]; matures atsubscriptionDate + 10 years. - Série F — subscriptions on or after
2023-06-01; units in[100, 100000]; matures atsubscriptionDate + 15 years. - Série E — subscriptions in
[2017-11-01, 2023-06-01](closed to new subscriptions); units in[100, 250000]; matures atsubscriptionDate + 10 years. - In all cases
asOfDatemust be on or aftersubscriptionDate. Past maturity, the simulation stops and reportsmatured: true.
- Série D — subscriptions in
The Portuguese-language methodology page maps every rule above to the source file that implements it: https://igcp-aforro.primor.me/methodology/.
Legal notice
This is an independent open-source project. It is not affiliated with, endorsed by, or sponsored by:
- the Agência de Gestão da Tesouraria e da Dívida Pública — IGCP, E.P.E., nor the Portuguese State;
- the European Money Markets Institute (EMMI), administrator of the EURIBOR® benchmark.
The package bundles daily EURIBOR® 3-month fixings sourced from the Deutsche Bundesbank time-series API (series BBIG1), which redistributes EMMI EURIBOR® data under non-commercial terms. EURIBOR® is a registered trademark of EMMI. Users that intend to use this library — or its bundled rates — for commercial purposes should review EMMI's terms of use and obtain any licence EMMI requires for their use case. Redistribution of the bundled fixings outside of this package may also require a separate EMMI licence.
Output produced by simulate() is a calculator-quality estimate, not an official IGCP statement. In any case of divergence between this library's output and IGCP's published values or your account statement, the IGCP-published values prevail. Nothing in this library or its documentation constitutes financial, legal, or tax advice.
Development
pnpm install
pnpm build
pnpm test
pnpm lint
pnpm typecheckRefresh the bundled Euribor dataset (developer-only; the cron in .github/workflows/data-refresh.yml does this automatically):
pnpm fetch:euribor
pnpm fetch:igcp-base-ratesMaintainers can run the live IGCP parity harness for targeted smoke checks or a
full local sweep; see the compare runbook in CONTRIBUTING.md
for examples and failure triage.
License
MIT © igcp-aforro contributors.
EURIBOR® is a registered trademark of EMMI and is used here for descriptive purposes only.
