UK gilt analytics platform: prices, post-tax yields and portfolio accounting for private investors, from the public DMO, Bank of England and Tradeweb feeds.
A UK private investor buying gilts is handed a clean price and a gross redemption yield, and neither answers the question that actually decides the purchase. Gilts are exempt from capital gains tax but their coupons are taxed as income, so the yield that matters depends on the buyer's band — and it moves again in April 2027 when the savings rate steps. Dirty price, accrued interest and indexation all shift the real return, index-linked gilts carry a three- or eight-month lag, and the accounting afterwards — cost basis across partial sells, cash-flow timing, taxable income by tax year — is work no retail broker does for you. The information exists, scattered across DMO, Bank of England and Tradeweb publications in formats built for institutions.
A three-module Spring Boot platform: a domain and calculation engine, a scheduler that ingests six public feeds and publishes a daily report on business days, and a server-rendered web application. Every in-issue gilt is priced by Newton–Raphson IRR at DECIMAL128 precision on the DMO semi-annual basis, then solved again at each income tax band so the site can show post-tax yield rather than gross. Portfolio accounting is an event-sourced ledger — append-only, folded on read and never stored as a position. The read path lives in an embedded DuckDB analytics store with S3 as the system of record; the front end is Mustache plus nineteen dependency-free JavaScript islands, with no SPA and no framework.
A live platform at giltiq.co.uk that prices all 104 in-issue gilts every business day and exposes the post-tax picture: bond and index-linked tables, gilt and real curves, a ladder designer, an income calendar, portfolio valuation and performance, taxable-income projection by tax year, and an authenticated assistant that answers questions against the user's own holdings. Every figure carries its method as a collapsed page note, and the assistant states facts rather than advice. Built and shipped in fourteen weeks as 1,309 commits, with almost one test file per source file and a suite of guard tests that read the templates and stylesheets verbatim to police the invariants.
GiltIQ is a production platform for UK government bonds, aimed at the private investor rather than the institution. It prices every in-issue gilt after tax, publishes a daily report on each business day, and layers portfolio accounting, cash-flow projection and tax-year reporting on top of it.
It runs at giltiq.co.uk. This is a write-up of how it is built and what the engineering decisions were.
A broker will tell you a gilt’s clean price and its gross redemption yield. Neither is the number that decides whether you buy.
Gilts are exempt from capital gains tax, but their coupons are taxed as income. So the yield a private investor actually receives depends on their marginal band — and it changes again in April 2027, when the savings income rate steps. On top of that sit the details that move real money: the dirty price is clean plus accrued interest and is the price you actually pay; index-linked gilts uplift both coupon and principal by an index ratio that lags by three or eight months; and a ladder of maturities produces a different income profile in every tax year.
Then the accounting starts. FIFO cost basis across partial sells, coupon dates, redemption proceeds, and a taxable-income figure that has to reconcile to a tax return. No retail broker does that arithmetic for you.
The platform is a feature module on the wider ninja-2.0 Spring Boot estate, split along the seam of what runs when:
| Module | Responsibility |
|---|---|
ninja-gilts-core |
Domain models, port interfaces, the yield and curve engines, the read store |
ninja-gilts-scheduler |
Feed ingestion, report publication, recompute and dispatch jobs |
ninja-gilts-web |
The public site, controllers, templates, JS islands, CSS |
The split that matters is horizontal, not vertical: everything importable lives in api/, everything else is intern/. The core module publishes roughly 130 port and read-model types and treats the rest of its own package as private. The six market-data feeds are not fetched by the core at all — they are ports, implemented by separate exchange modules outside the platform. The core has no reverse dependency on any of them, which is why the same engine can be driven by fixtures in a test and by Tradeweb in production without a conditional anywhere in the domain.
Yield is solved by Newton–Raphson IRR over the semi-annual coupon schedule, in BigDecimal at DECIMAL128, to a tolerance of 1e-10 over a hundred iterations. Index-linked gilts are priced against the Bank of England’s real curve, with the index ratio derived from the D1A uplifted and nominal amounts rather than reconstructed from RPI — an earlier approach that produced a discontinuity and was removed deliberately.
The part worth explaining is why there are two yield bases. Gilts are conventionally quoted on the UK DMO semi-annual convention, and that is the canonical figure — the one that has to match an external source. But headline comparisons and most retail material use annually compounded yields. Converting between them silently would be the easy option and the wrong one, so the engine carries both: the DMO basis as canonical, plus annually-compounded twins that are labelled as such wherever they surface. A CALC_VERSION string is stamped into every published figure, so a history computed under an older methodology stays interpretable instead of quietly contradicting today’s number.
Cost is measured rather than assumed. One valuation runs ~4.3 ms warm; a business date is 104 gilts across three settlement dates, so 312 valuations and roughly 3.6 seconds of work. Of that, about two-thirds is arithmetic and one-third is S3 latency — which is why the read path moved into an embedded store rather than being micro-optimised in the solver.
Because the post-tax yield is the product’s whole reason to exist, tax is modelled as data and computed at read time rather than baked into stored figures. Band boundaries are held as a seam (GiltTaxBandApi), so introducing the blended bands for the April 2027 savings-rate step is a data change and not a migration of every published number.
The taxable-income projection is the most involved surface: it takes the portfolio’s coupon and redemption events, allocates them to tax years on the actual payment calendar, and drills each security down to its individual events. A tool that shows a total you cannot audit is worse than no tool, so every projected figure can be opened to the rows that produced it.
Beyond the published Bank of England curves, the platform fits its own per-date gilt curve. The fitter is a Nelson–Siegel–Svensson model with a Nelson–Siegel fallback, fitted by Nelder–Mead over the decay parameters with ridge-regularised least squares inside.
The design constraint that shaped it: no random number generator anywhere, and a fixed 25-seed multi-start. A curve fit that lands somewhere slightly different on each run is not reproducible, cannot be regression-tested, and cannot be explained to anyone looking at two charts that disagree. Fixing the multi-start turns an optimisation into a pure function of its inputs — the same date and the same gilts always produce the same curve, so the stored series can be diffed across releases.
Trade history is event-sourced. Every trade is an immutable event keyed by object key; corrections and reversals are new events appended to the log, never in-place edits. On read, the log folds down to one effective event per live trade, and FIFO cost basis is computed from that fold.
Nothing stores a position. The alternative — a stored position row updated on every trade — is the classic source of a portfolio that slowly stops agreeing with its own history. Fold-on-read means the position is always a restatement of the log, and the fold throws rather than guessing if it would ever go negative.
Trades carry clean price, accrued interest and index ratio as separate inputs, with the dirty price authoritative and derived only through one constructor path — because clean and dirty getting confused is the specific bug this domain punishes hardest. Performance is reported over six Modified Dietz windows, and book yield is the one figure the platform refuses to convert silently: it is always quoted on its labelled DMO basis.
User data lives in S3. The analytical read path lives in an embedded DuckDB store projected from it.
The rule that came out of an operational failure: a store miss is answered empty, never quietly filled from S3. Serving from S3 on a miss makes the site look healthy while the projection behind it has silently stopped, and the failure only surfaces weeks later as figures that will not reconcile. The platform distinguishes exactly two states — no store at all, which is an informational, supported S3-only deployment, and a store that exists but misses, which is an error.
Feed completeness is tracked the same way, in a registry that records a verdict per feed per date. The principle written into it is that presence is recorded evidence and absence is not: a missing day is never inferred from a gap between two present ones.
The daily report is not published at a fixed time. An hourly job on business days checks whether all six feeds have landed and are complete, then builds and publishes when they are — so a source that runs late delays the report instead of publishing a report with a hole in it, and a deadline job raises an explicit “report missing” alert rather than letting a stale page sit there.
Every job is guarded by a per-job tryLock, logs its resolved business date on each tick, and computes that date as the previous London business day — weekends and England-and-Wales holidays excluded, with a weekday-only degradation and a warning if the holiday feed is unavailable.
One decision here was forced by an incident rather than designed. Each instance now declares a deployment role: producer or consumer. A consumer holds no producing beans and refuses every producing tick at runtime, belt and braces. The motivation was concrete — a laptop running the scheduler against the shared bucket wrote staging data into a production feed, and writes to a shared S3 prefix are not reversible.
Pages are Mustache templates rendered on the server. There is no client-side rendering of primary content, so every figure is in the HTML that arrives, which is what makes the site usable, printable and indexable.
Interaction is layered on as islands: nineteen small dependency-free scripts, each of which no-ops without its markup. The page’s head emits only the islands its own markup asks for, so a page with a table does not pay for a chart library. Each island has a guard test, and the served files are generated — the source is the thing you edit.
Chart.js is not vendored as the standard UMD bundle. A small separate build tree-shakes it down to the controllers and scales actually used, which is the difference between ~205 KB and the fraction of it the site needs.
Two performance decisions are worth naming because they are policy, not tuning. Fonts are self-hosted, so no page that reports financial data calls a font CDN. And the stylesheet is served from a content-hashed filename with an immutable cache header — 26 partials assembled in cascade-layer order, where every partial belongs to one named layer and only the @property registrations and @font-face declarations sit deliberately outside the system, so a rule’s precedence is answerable from the file it lives in.
Theming is two modes over one token set, and every token’s contrast is computed rather than eyeballed — WCAG 2.2 AA, 4.5:1 for text and 3:1 for non-text, in both themes, enforced by a guard test that fails the build if a colour drifts below the threshold. The accent colour is deliberately two roles rather than one value, because plain gold as text is 2.2:1 on paper and only works as a fill with a separate label colour.
The subtlety specific to this domain: a rising yield is not a gain, and a falling one is not a loss. Direction is encoded neutrally, because a red/green pair would tell a gilt investor something false about the number next to it.
Tables get scope and aria-label, destructive actions are tiered by consequence with credential changes always requiring a reviewed confirm, and there is a skip link. The “explain” mode — which annotates every figure for a newcomer — is server-rendered and always present in the DOM rather than toggled into existence, so it works for a screen reader and a printed page alike.
The AI features are behind an authenticated session: ask about your own portfolio, a gilt’s history, or what a term means.
The model is behind a port, with one implementation. What the model can do is defined server-side as an allow-list of tools — gilt lookup, history, search, and read-your-own-portfolio — and the portfolio tool resolves identity from the authenticated session rather than from anything the model supplies. Tool rounds are capped at five, price history is thinned before it reaches the context, and the whole call sits under a 25-second budget beneath a 30-second browser abort.
The governance rule is the same one that governs the reminders feature: the platform states facts and never suggests an action. GiltIQ is information, not advice, and that is a constraint on the copy generation as much as on the charts.
The repository holds 470 source files and 443 test files. That ratio is not accidental — the domain punishes arithmetic errors quietly, so the arithmetic is pinned rather than sampled.
Three layers do most of the work. Golden tests assert 140 valuations in 0.6 seconds against stored expectations, so a change in the solver fails loudly. Provider-agreement tests pin the platform’s figures against FTSE-Tradeweb’s own published numbers at 1e-5, and their stated acceptance criterion is inverted: the test is only meaningful if it has been red, so it was proven to fail before it was allowed to pass.
The third layer is unusual and is the one that has paid for itself most: around eighty guard tests that read templates, stylesheets and scripts verbatim off the classpath and assert invariants about them. No inline <style> anywhere. Exactly two inline scripts, and only the pre-paint theme ones. No static inline styles. Every table inside the responsive wrapper carries its accessibility attributes. The stylesheet as generated still matches its token contract. Contrast still passes. A new island or partial is expected to arrive with its guard in the same change.
Browser tests run against real rendered pages through Playwright, and the integration tests assert on the HTML rather than on a view name — because a view name resolving is not the same as a page working.
Fourteen weeks, 1,309 commits, one person. The site is live, prices all 104 in-issue gilts each business day, and runs unattended.
The most valuable artefacts in the repository are not the code but the decision records inside it. Several sections of the module documentation exist specifically to record decisions that were reversed — the index-ratio reconstruction, the S3-on-miss fallback, an earlier store-parity approach to testing — together with the measurement or incident that justified the reversal. A codebase that only records what it does teaches the next reader to re-litigate settled questions; one that records why a plausible approach was abandoned does not.