Working notes — not an issue of Mail Club. No theme, no crest, no generated textures. Raw material. ← the whole run

Build log · 27 August 2026 · currency-portfolio-tool

From Snapshot to Ledger

One session that took the tool from a frozen JSON file read by a static page to a transaction ledger, a daily valuation measured from each holding’s buy date, and a live API serving both. Plus a bug that reached production, and what it changed.

Commits
7176d613 → c41fd54
Files
1710 new, 7 modified
Lines
+4,336−124
Checks
299across 5 suites
Broken on purpose
4to prove tests bite
Production bugs
1caught in ~2 min

What actually changed

Before this session the tool made one claim: five holdings, 20% each, measured over whatever 365-day window happened to be downloaded. There were no shares, no purchase, no dates — just an assertion.

It now stores the trades and derives everything else from them. Returns are measured from the day each holding was bought, using the units actually held on each day, across a mid-period switch that changed two positions. The dashboard asks a server rather than reading a file, and says on screen which of the two bases it is showing.

The same question, asked of the same portfolio, before and after

  BEFORE  weights = 20% each, typed into Python
          "what did I hold in June?"        unanswerable

  AFTER   2026-03-20   ADS.DE  32,940 units   CFR.SW  29,245
          2026-05-01   ADS.DE  16,470 units   CFR.SW  43,305
          neither figure is stored; both are derived from the trades

The session, in order

Timestamps are the real commit times. The session ran roughly two hours of wall clock from the first orientation to the final documentation pass.

before 21:31

Orientation, and a wrong directory

The session opened in ~/currency-portfolio-tool — an empty git init with no commits, no remote and no files. The real project is ~/projects/currency-portfolio-tool, on main, pushed to GitHub. That cost two tool calls to discover and is now saved as a memory so it does not cost them again.

Read CLAUDE.md (562 lines at that point), the README, and — at Alice’s suggestion — /learn/scorecard.html, the Issue 08 write-up of the V1 build. That last one set the working standards followed for the rest of the session: hand-made test inputs get exact assertions while real market data gets structural ones; a test that has never failed is not yet a test; limitations get written down rather than implied away.

planning

A sequencing problem in the roadmap

The V2 roadmap in CLAUDE.md was ordered by dependency — backend, persistence, auth, buy dates, ledger — with two “product ideas” listed after. That is the right way to record decisions and the wrong way to build them, for two reasons.

The two cheapest items were at the bottom. Currency-level exposure is one groupby over data V1 already produced. Its companion, pricing what a hedge would have avoided, is one multiplication on top. Neither needed a backend, a database or authentication.

The stated dependency between the backend and persistence was not real. The ledger is pure Python and SQL and can be built and tested with no HTTP anywhere — the same way the hedging path was already tested.

One claim in the roadmap was checked rather than assumed. It said every holding in a currency should share an identical FX return, and to verify that before relying on it. It does, exactly, and it is true by construction: _cross_rate() depends on the currency pair and never on the ticker.

Verified from the committed JSON before building anything on it

  ccy     weight      exposure  fx_return  fx contrib        fx P&L
  EUR     60.0%    12,000,000     -1.06%     -0.638%      -127,598
  CHF     20.0%     4,000,000     -0.73%     -0.145%       -29,023
  USD     20.0%     4,000,000     -1.08%     -0.216%       -43,227
  ALL    100.0%    20,000,000                -0.999%      -199,847

  portfolio.fx_pnl in the committed file: -199,847.03   reconciles

Two decisions were put to Alice: where to start, and what the persistence layer should be. The first attempt at asking was pitched in industry shorthand and had to be redone in plain language — a correction that shaped the rest of the session’s explanations. The answers were the ledger first and Postgres with hand-written SQL.

21:31 3228ea4

The transaction ledger

Four tables, the constraints, and append-only triggers. schema.sql, db.py, ledger.py, seed_portfolio.py, test_ledger.py. 1,177 lines.

The organising idea is that the ledger is the only thing stored. Positions, cash and weights are queries. Ask what was held last June and the answer is recomputed from the trades that existed in June, rather than read from a snapshot that could have gone stale.

Cash as an instrument

Cash is CASH.GBP, CASH.EUR and so on — not a special case. Buying an equity debits cash in the instrument’s own currency implicitly, so nobody enters a cash balance and nobody can enter one wrongly. An FX conversion is two rows sharing a leg_group: withdrawing sterling and depositing euros is one act, and neither leg means anything alone. Without this the portfolio silently loses money between a sale on Monday and a purchase on Thursday.

Whole shares, and what falls out of them

You cannot buy 0.37 of a share. Rounding down five purchases leaves real money behind, and that residual is part of the portfolio — a total that omits it is wrong. It also has a useful side effect: because units are whole, units × price has no rounding left in it, so every foreign cash balance nets to exactly zero and the residual can only ever land in the base currency.

=== opening ledger, Friday 6 March 2026 ===
    buy date from car number #63

holding   ccy       units        price       rate      cost, local      cost, GBP
ADS.DE    EUR      32,940     140.0713   0.866922     4,613,949.27   3,999,933.10
PUM.DE    EUR     205,983      22.4000   0.866922     4,614,019.12   3,999,993.66
CFR.SW    CHF      29,245     142.7000   0.958460     4,173,261.41   3,999,902.26
PVH       USD      82,211      64.8846   0.749870     5,334,230.18   3,999,979.18
MBG.DE    EUR      90,142      51.1862   0.866922     4,614,024.99   3,999,998.75
invested  GBP                                                       19,999,806.95
residual  GBP                                                              193.05

cash by currency:   CHF 0.00   EUR 0.00   GBP 193.05   USD 0.00
  No currency is overdrawn: every purchase was funded before it settled.

Foreign cash nets to exactly zero because units are whole numbers. The £193.05 is rounding, and it is real.

The trigger caught its own author

The append-only rule is enforced by database triggers, not by convention. Its first test was unplanned: the seed script’s initial draft cleared the table with DELETE and the database refused it — “transactions is append-only: DELETE rejected on row 1. Post a reversing entry instead.” About ninety seconds after the rule was written.

Which exposed a real hole. TRUNCATE does not fire row-level triggers in Postgres, so TRUNCATE transactions would have quietly emptied a table advertised as append-only. That is worse than no rule, because it reads as protection. A second, statement-level guard now covers it, and reseeding disables both by name in a function that says loudly why.

Buy dates from car numbers

Alice proposed deriving buy dates from F1 car numbers: George Russell’s 63 reads as the 6th of March. It works — and it needed a rule, because the joke only survives by luck otherwise.

#63  Russell    -> 2026-03-06 (Friday)     a trading day
#44  Hamilton   -> 2026-04-04 (Saturday)   markets shut
#12  Antonelli  -> 2026-02-01 (Sunday)     markets shut

The number proposes a date; next_trading_day() disposes. Forward, never back — a purchase cannot predate the instruction to make it. #44 was used for the mid-period switch precisely because it lands on a Saturday, so the rule is exercised rather than theoretical.

68 checks in two layers: a hand-built ledger inside a transaction that is always rolled back, so exact assertions run against the real constraints and real triggers; and the seeded sample portfolio for structural ones. Then the boundary was broken on purpose — <= changed to < — to watch seven checks go red before restoring it.

21:31 674821d

Currency exposure, and pricing a hedge

The portfolio holds five companies but only three currencies. Three euro holdings each down 1.06% is not three findings — it is one exposure seen three times, and what makes it matter is invisible in the per-holding view.

Both views were kept. Alice was explicit about this, and test_dashboard.js now fails if any ticker or company name stops appearing on the page.

The threshold did not move

A tempting change was declined. The −1.5% limit still tests a currency’s own move, not its weighted contribution to the portfolio. Re-pointing it would have changed what a documented number means while leaving it looking identical — and nothing would ever have breached again, since the largest contribution was −0.64%. Concentration is reported alongside instead, as the thing that decides whether a breach matters.

Two hedges, and the gap between them

What hedging would have avoided is quoted twice. A forward struck on day one fixes the rate on the money that was there then, so it removes the FX return and nothing else. A rebalanced hedge is adjusted as the position moves, so it also covers the currency move on the gain the asset made along the way.

The gap between those two figures is exactly the cross term. That number only exists because this project reports the cross term separately instead of folding it into the FX return — the clearest payoff yet for a decision made in V1.

ccy      forward_50%   rebalanced_50%    forward_100%   rebalanced_100%
EUR           36,198           36,385          72,397            72,770
CHF           17,571           24,642          35,143            49,283
USD           20,939           20,152          41,878            40,303

Richemont is up a lot, so its cross term is large and the rebalanced hedge is worth £14k more. PVH fell, so its cross term is positive and the rebalanced hedge is worth £1.5k less. A hedge on a currency that rose shows a negative saving, because it would have cost money.

Three test failures in this phase were informative. Two were real: a number split from its unit across a source line break, which would have rendered as awkward text. One was the test being too fussy about where a <strong> tag fell. Two template fixes, one assertion relaxed.

The tests were then proved to bite by dropping the weight out of the contribution — the mistake that would look most plausible — and watching three checks fail.

21:48 826864a

Giving the dashboard something to ask

Alice asked why the portfolio page was frozen when the Issue 08 panel on the scorecard is live. The answer was concrete: the scorecard asks a route on her server on every page load, and the portfolio page read a file that was frozen at deploy time. She had already built the mechanism — for a different page.

The constraint that shaped this

The service ran on /usr/bin/python3, which has fastapi and psycopg2 but not pandas, numpy or yfinance — and the entire calculation core needs all three. So the service had to move to a venv that has both sets. fastapi, uvicorn and httpx went into the project venv, and the unit file was pointed at it.

Two safeguards, because that one process serves the whole of alice.charliehub.net:

  • The portfolio code is imported on first request, not at startup. A broken import becomes a 503 on one route instead of a site that will not start.
  • The complete app was run under the new interpreter on a spare port first, and every existing route checked — guestbook, probes, market, scorecard, the static site — before anything was changed.

The restart itself was blocked by the permission classifier and handed to Alice to run, which is the correct outcome for a command that restarts the service behind an entire website.

=== existing routes still work under the new interpreter ===
  200  /api/health          200  /api/market/ADS.DE
  200  /api/guestbook       200  /learn/scorecard.html
  200  /api/probe           200  /portfolio/            200  /

=== new portfolio routes ===
  200  /api/portfolio/health      200  /api/portfolio/trades
  200  /api/portfolio/positions   200  /api/portfolio/summary
A process that would not die

A test server started earlier survived its kill. The second one failed to bind, and the curls silently hit the stale process — visible only because a supposedly cold first call reported cached: true, age 290s. Caught, killed, and the check re-run cleanly. Exactly the failure mode CLAUDE.md warns about, arrived at from the other direction.

22:07 f2009ab

Daily valuation, and the decision that had been flagged as dangerous

This was the piece the whole session had been building toward, and it contained the one judgement call recorded in CLAUDE.md as most likely to produce a wrong number that looks convincing: chain-linking.

The algebra was checked on paper before any code was written. It turned out to be narrower than the worry.

one day, one holding, in pounds
  local    3,400.0000
  fx      -2,000.0000
  cross      -80.0000
  sum      1,320.0000
  total    1,320.0000
  exact? True

two days, one holding, chain-linked percentages
  local   1.000000%     fx   1.176471%     total  2.188235%
  (1+local)(1+fx) = 2.188235%
  identity holds?  True

For one holding, the identity survives chain-linking exactly. Chaining daily returns gives ∏(1+l)(1+f) = ∏(1+l) × ∏(1+f), so (1 + total) = (1 + local) × (1 + fx) still holds over any number of days. Nothing had to be given up at the holding level.

At portfolio level it does not, because each day’s component is a weighted sum across holdings, and the average of a product is not the product of the averages. That is the real problem, and it has a literature of smoothing algorithms attached to it.

The answer is to attribute in money rather than percentages. For one holding on one day, with u units held through it:

local = u (p1 - p0) r0
fx    = u p0 (r1 - r0)
cross = u (p1 - p0) (r1 - r0)
total = u (p1 r1 - p0 r0)

These three sum to the total exactly, by expansion — no approximation and no scaling factor. Sum over 124 days and five holdings and it still reconciles, because only addition happened. Percentages are then quoted as a share of the opening value and labelled as contributions, which is what they are. No smoothing algorithm is needed and none is used.

Three consequences worth naming

Trades execute at the close, so the units held through a day are yesterday’s close position. A trade earns nothing on the day it was made, having been struck at the price used to value it. Tested in both directions — and this is the rule that was broken on purpose to prove the tests bite, failing exactly two checks and nothing else.

Returns and P&L now answer different questions. A return is per share, since purchase, whatever number were held. P&L is money, and reflects what was actually held each day. Adidas shows +6.41% and only £31,035 because half of it was sold in April.

Weights are derived and they drift. The “weights are start-of-period” caveat that had been carried since V1 is dead.

holding      units    bought   return since         P&L    share now
ADS.DE      16,470     6 Mar         +6.41%     £31,035        9.5%
PUM.DE     205,983     6 Mar        +10.38%    £415,230       19.8%
CFR.SW      43,305     6 Mar        +27.70%  £1,661,023       33.9%
PVH         82,211     6 Mar        +18.81%    £752,256       21.3%
MBG.DE      90,142     6 Mar        -13.75%   -£549,891       15.5%
PORTFOLIO                          +11.55%  £2,309,653      100.0%

opening 20,000,000.02 -> closing 22,309,652.69   over 124 trading days
  local   2,835,895.49
  fx       -522,013.23
  cross      -4,229.60
  sum     2,309,652.66     total   2,309,652.67
  components reconcile to the total: True
22:26 06b393b

The dashboard, and a bug that reached production

Two documents now existed, describing the same portfolio differently and correctly. Rather than write two renderers that would drift, normalise() maps either onto one shape, and the page states in its header which basis is on screen.

New on the page: a portfolio-value line over every trading day since inception, units held and purchase date per holding, and a note in words explaining that the return columns are per share while the P&L column is money.

A test that found a real gap

Grouping the money attribution by currency needs no weights at all — a currency’s figure is the sum of its holdings’. But a check that the weights add to 100% failed. The cause was real: cash had no row. The £247 of leftover sterling was in the portfolio total but not in the currency table, and an earlier printout had rounded the gap away. The table was fixed rather than the test, and the base currency now shows a 0.00% FX move — correct rather than awkward.

Shipped broken, fixed in about two minutes

The page was deployed expecting a currencies block that the running service was not yet serving. A live service imports the calculation core once and keeps it in memory, so it went on serving the older document shape until restarted. The figures it served were correct. The page threw on the missing block, and a visitor would have seen nothing at all — the HTML returned 200 the whole time.

Caught by rendering the deployed page against the live document in Node before declaring the work done. Worth doing after any deploy that changes the contract between a page and its data, and cheap enough that there is no excuse not to.

The fix is not “remember to restart”. normalise() now treats every block it did not write itself as optional and renders what arrived; a regression test renders a document with those blocks deleted. A page must never depend on a service having been restarted.

One result that changed

On the ledger basis, two currencies breach the −1.5% threshold — the franc at −4.59% and the dollar at −1.81%, together 55% of the book. Nothing breached on the V1 basis. The period is five and a half months rather than a year, and it is a different story. The flag path has now fired on real data rather than only on test data.

ccy    held    weight      exposure  of which cash   fx move    status
EUR       3  44.7941%  9,993,412.48           0.00    -1.10%        ok
CHF       1  33.9035%  7,563,757.85           0.00    -4.59%    BREACH
USD       1  21.3013%  4,752,235.17           0.00    -1.81%    BREACH
GBP       0   0.0011%        247.19         247.19     0.00%        ok
ALL       5 100.0000% 22,309,652.69

The euro is 44.8% of the book on this basis, not the 60% the fixed-weight view showed — the April switch and market moves changed the concentration.

22:35 c41fd54

Editorial pass over the documentation

Both documents had grown by accretion across the session and read like a changelog rather than a description of the tool. CLAUDE.md had four dated “step N done” sections stacked in the order they happened, consolidated into one Version 2 section: what exists, decisions grouped by subject, the production bug, and an explicit list of what V2 did not do.

Three statements in the V1 half had quietly become false and were corrected — including an Architecture note reading “No live backend”, and a V2 preamble reading “Nothing in V2 is live”, which was true for about six hours.

The README was rewritten to describe the tool as it is rather than V1 plus five appended paragraphs. “Positions are start-of-period, so weights don’t drift” was deleted rather than softened. Check counts in both files were verified against a run rather than remembered.

Every decision taken, and why

The reasoning matters more than the choice in most of these — a decision recorded without its argument gets reversed by whoever is next in a hurry.

AreaDecisionReasoning
storagePostgres, not SQLiteAlready running and already used by guestbook; SQLite would be a second engine in one project, and its CLI is not installed here so it would be harder to inspect.
storageHand-written SQL, not an ORM“Positions are a query” is the idea worth learning. An ORM would generate exactly the query worth reading.
ledgerCash is an instrumentBuying debits cash implicitly, so nobody enters a balance and nobody can enter one wrongly. Without it money vanishes between a sale and the next purchase.
ledgerFX conversion is two linked rowsWithdrawing sterling and depositing euros is one act; neither leg means anything alone.
ledgerBUY/SELL for equities, DEPOSIT/WITHDRAW for cashOne word for both would hide the difference between an investment decision and a funding one. Enforced by a composite foreign key.
ledgerWhole shares onlyYou cannot buy 0.37 of a share. The residual is real money; it also makes foreign cash net to exactly zero.
ledgerAppend-only enforced by triggersPolicy that lives only in a comment gets broken by whoever is in a hurry. TRUNCATE needed its own guard.
ledgernumeric, never floating pointA ledger that does not add up exactly is not a ledger. The returns maths stays on floats — measurement, not accounting.
ledgerBackdating allowed, amendment notA trade entered late is legitimate; history changing is the feature. A correction is a reversing entry.
ledgerAverage cost, not FIFOSimpler, and it matches HMRC’s Section 104 pooling for a GBP investor — a domain reason rather than a convenience.
ledgerBuy dates from car numbers, rolled forward63 happens to be a Friday; most numbers are not. Forward, never back — a purchase cannot predate its instruction.
mathsAttribute in money, not percentagesPercentages compound so they do not add. In pounds the three components sum to the total exactly, with no smoothing factor.
mathsUnits held through a day are yesterday’sTrades execute at the close, so a trade cannot earn the move it was not there for.
mathsReturns per share, P&L in moneyThey answer different questions. A holding sold down shows a positive return and a small P&L; both are correct.
mathsTWR labelled, not assumedWith one subscription and no flows since, TWR and MWR are the same number. They diverge later; the label is not yet earned.
currencyThreshold still tests fx_returnA −1.5% limit is a statement about how far a currency moved. Re-pointing it would change what a documented number means while leaving it looking identical.
currencyBoth breach counts published1 currency and 3 holdings are one event counted two ways. A reader must never have to guess which number they are looking at.
currencyDisagreeing rates raise, never averageIt would mean holdings were priced against different bases, and an average would hide it under a plausible number.
currencyCash gets a currency rowOtherwise the weights quietly fail to add to 100%.
currencyHedge cost not netted offPricing a forward needs interest-rate differences; Frankfurter publishes FX only. Not estimated, not faked.
servingService runs under the project venvThe core needs pandas/numpy/yfinance; none are installed system-wide and PEP 668 says they should not be.
servingCore imported lazilyThat process serves the whole site. A broken import must be a 503 on one route, not a dead website.
servingStatic file is a fallback, not retiredIt works when the API is down and under a plain http.server, and stays the record of what the tool said on a given day.
servingA page never depends on a restartLearned the hard way. Every block the page did not write itself is optional.

Testing, and four deliberate failures

Every suite follows the two-layer pattern the Issue 08 write-up set out: hand-made inputs get exact assertions against real constraints; real market data gets structural ones, because prices move and a test that fails every morning teaches you to ignore it.

Four times a piece of working code was broken on purpose to confirm the tests would catch it — because a test that has only ever passed has not been shown to work. In each case the failures were checked for precision, not just presence: the timing break failed exactly two checks and nothing else, which is a much better signal than a suite going uniformly red.

SuiteChecksWhat was broken to prove it bites
test_flags.py39— inherited from V1
test_currency.py50Dropped the weight from the contribution → 3 failed
test_ledger.py68Changed <= to < on the date boundary → 7 failed
test_valuation.py66Removed the one-day lag on units → exactly 2 failed
test_dashboard.js76Deleted blocks from a document → regression test added
total299

What this session did not do