Build Portfolio Coach
KINDAback to the verdict
A household tracker whose hard problems are deriving positions from a transaction ledger you can reconcile, never displaying a valuation without its source and age, and keeping a model review on the describing side of a regulated line.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the tracker locally.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - have readyfree
Why Reconciliation is the only thing that proves the ledger is right, and it needs a figure produced by somebody other than your own code.
Get it Download transaction history and a valuation statement from the same date for two accounts.
Verify
Derived positions match the statement to the penny - have readyfree
Why Every figure on the planning page comes from it, and a figure with no source and no review date is a number that will be quietly wrong.
Get it Read the current published thresholds on the revenue authority's own pages, record the value, the tax year, the source link and the date you checked, and set a review date. open ↗
Verify
Every row has a source, a checked date and a review date, and a test asserts it - decidefree
Why A tracker with a model attached drifts towards advice one helpful feature at a time, and the boundary has to exist before the first review is generated.
Get it Write down that no output names an instrument to buy, sell, hold or switch, that no segment-based suggestion is produced, and what the output check refuses.
Verify
The adversarial prompt test enforces the same list the README states - API keypennies per review
Why The review is the only part that leaves the machine, and it should be the only part with a spend limit and a provider-side ceiling.
Get it Create a key with your preferred provider and set a spend limit in their console.
Verify
A review runs and the payload snapshot test passes
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- people(id, name, relationship) and accounts(id, person_id, wrapper, provider, currency, opened_on) - transactions(id, account_id, at, kind, instrument_id, units, price_minor, price_currency, fee_minor, fx_rate, fx_rate_date, source_row) · the record everything else is derived from - instruments(id, symbol, name, currency, kind, identifier_kind, identifier) · the identifier kind matters because the same fund answers to several - prices(instrument_id, as_of, price_minor, currency, source) · a price with no source and no date is not shown - positions_cache(account_id, instrument_id, as_of, units, book_cost_minor) · derived, rebuildable, never authoritative - assumptions(id, name, value, unit, set_by, set_at) · every projection input, attributed to the person who typed it - tax_rules(id, jurisdiction, name, value_minor, tax_year, source_url, checked_on, review_by) · the planning page refuses once the review date has passed - reviews(id, at, model_id, prompt_version, input_summary_json, output_text, refusals_json) · what was sent and what came back, because 'the app told me to' needs an answer - There is no table for a recommendation, a target allocation or a suggested trade. There is nowhere to put one, and that absence is the design.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/portfolio.db | The ledger, the prices, the assumptions and the reviews. Back it up properly. |
RULES_PATH | required | ./data/rules | Dated tax rules files. Updates add rows; nothing is edited in place. |
PRICE_STALE_HOURS | required | 48 | Past this a price is marked stale and its age appears on the headline. |
PRICE_FEED_KEYsecret | optional | pk_...redacted | Optional. With it absent the application values everything from manual prices. |
MODEL_API_KEYsecret | optional | sk-...redacted | The review. The only outbound call, and the only thing with a spend ceiling. |
MODEL_MONTHLY_CEILING | required | 2.00 | Reviews stop at this figure. The tracker carries on without them. |
EXPORT_PASSPHRASEsecret | required | a-long-passphrase-redacted | Encrypts the export. Losing it loses the export, not the database. |
BASE_CURRENCY | required | GBP | Everything is converted into this for totals, with the rate and its date stored. |
The build, in order
A ledger you can reconcile, not a list of holdings
Positions are derived from transactions and can be checked against a real statement.
Everything else is a view and should be deletable without loss.
Files
src/db/schema.sqlsrc/positions.tsA pretty number nobody has checked is worse than no number.
Files
src/app/reconcile/page.tsxtests/reconcile.test.tsThe rate on the day is part of the transaction, not a lookup.
Files
src/import/map.ts
done when · tick each as it passeswatch out- Letting someone type today's holdings and calling it a portfolio leaves no cost basis, no history and nothing to check against anything, so every gain figure is a guess and a double import goes unnoticed for a year. Store transactions, derive positions, and put a reconciliation screen in front of the dashboard so a mismatch is visible on the day it appears rather than at the next tax year.
No valuation without its source and its age
Every figure on the screen can be traced to a price, a source and a time.
A price without provenance is a rumour with two decimal places.
Files
src/prices/store.tsIt is the one number that tells you whether to trust the others.
Files
src/app/page.tsxManual prices are the floor, not the fallback.
Files
src/prices/manual.tstests/no-feed.test.ts
done when · tick each as it passeswatch out- Falling back to the last known price when a feed fails makes a portfolio valued on Friday look as though it was valued this morning, and the first time anybody notices is when a figure somebody acted on turns out to be a fortnight old. Stamp every price with its source and time, mark stale ones visibly, and put the age of the oldest price next to the headline total rather than behind a hover.
Projections made only of assumptions somebody typed
The chart cannot be drawn until every input has been supplied and attributed.
An unowned number becomes everyone's number.
Files
src/projection/inputs.tsA single line is a forecast and you do not have one.
Files
src/projection/chart.tsxSo the screenshot arrives with its own footnotes.
Files
src/projection/assumptions.tstests/projection.test.ts
done when · tick each as it passeswatch out- Shipping a default growth rate means the chart somebody screenshots and shows their partner was produced by a number your application chose, and every conversation afterwards is about a figure nobody in the room decided. Ship none: leave the projection page empty until the assumptions are entered, attribute each one, and carry them on the face of the chart rather than in a panel underneath.
A planning page that expires
Every tax figure has a source, a checked date and a review date, and the page stops rather than ages.
The figure and its expiry are one fact.
Files
data/rules/uk-2026.jsonsrc/rules.tsShowing nothing is honest; showing last year's threshold is not.
Files
src/app/planning/page.tsxtests/expiry.test.tsA disclaimer in the footer is a disclaimer nobody reads.
Files
src/components/TaxFigure.tsx
done when · tick each as it passeswatch out- Hard-coding a threshold that has been frozen for years feels safe precisely because it has not moved, and then a Finance Act extends the freeze again and your figure is right for the wrong reason until the year it silently is not. Put every figure in a dated file with its source, let the page refuse once the review date passes, and make updating the file a new row rather than an edit so an old estimate can still be explained.
The line the software will not crossproduct builder
The model describes and questions, the household's data stays inside, and everything exports.
Snapshot the payload in a test so a later change is visible.
Files
src/review/payload.tstests/payload.test.tsEditing out a sentence leaves you with an edited recommendation.
Files
src/review/guard.tstests/adversarial.test.tsA household tracker is several people's private information.
Files
src/auth/scope.tssrc/export.ts
done when · tick each as it passeswatch out- An open-ended 'review my portfolio' prompt returns a suggestion about a specific holding roughly every other time, because that is what a portfolio review looks like in the material the model learned from, and once it is on the screen the household has received something that reads exactly like advice from a tool sitting in their own house. Keep an adversarial prompt set in the suite, check the output before it renders, and refuse the whole response rather than deleting the offending sentence.
That is the whole plan for Portfolio Coach. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Any suggestion about a particular investment.
- Segment-based suggestions.
- Broker and bank connections.
- A definitive tax calculation.
- reliable live pricing and UK fund symbol mapping
- ledger-backed Vanguard history import
- secure cloud accounts, family sharing, and support access
- maintained UK tax and estate-planning assumptions
- hosted AI reviews with current web context
- Corporate actions handled as transactions, since a share split silently breaks every derived position
- A second jurisdiction's rules file, which is the real test of whether the rules abstraction holds
- A dated snapshot of the whole household position, so a projection made last year can be scored against what happened
Need the files? The project pack on the verdict page hands your agent the whole brief · more personal finance.