Value positions from a daily price feed

A position was a share count. An instrument now carries a market symbol and
the last close fetched for it, so Wealth and the dashboard report cash plus
market value instead of cash alone.

The symbol is chosen by hand and never derived: one ISIN lists on several
exchanges in different currencies, and a price from the wrong listing misstates
wealth without failing any check. The refresh refuses a quote whose currency
differs from the instrument's, keeps the previous quote when a symbol cannot be
priced, and counts an instrument with no symbol as unpriced - naming it in a
check and leaving it out of every total, because cost is not value. The quote
belongs to the job: saving an instrument can neither set nor erase it, and
changing the symbol discards it.

Two things the provider forced. It answers HTTP 429 to every request whose
User-Agent names a programming language, so the client identifies as a browser;
without that header the first call of the day fails. Its closes are 32-bit
floats widened to 64 - 165.26 arrives as 165.25999450683594 - so a figure is
rounded to seven significant digits, which is what 24 mantissa bits carry;
eight would have stored 165.25999 as a price.

Accepted quotes are written in one commit against a revision re-read after the
fetches, and nothing is committed when no quote changed. The automatic run
starts shortly after launch and repeats daily on its own timer, so a sync
backoff cannot delay it and prices arrive with no bank connected.

Verified against live quotes end to end: 80 shares at 125.45 and 40 at 165.26
on 6000.00 cash report 22646.40 with one holding named as unpriced; giving that
holding a symbol through the UI moves the figure to 23530.50, and a second
refresh leaves the revision untouched.
This commit is contained in:
Lars Nolden
2026-09-12 18:42:07 +02:00
parent 2373790be3
commit 588c16ad19
19 changed files with 1580 additions and 55 deletions
+15 -3
View File
@@ -2,7 +2,7 @@
A self-hosted personal finance dashboard with a **Go backend**, **React frontend**, and **DuckDB analytics**. Human-readable `.finance` journals are the source of truth; DuckDB is a disposable index.
Imported bank facts are separate from editable merchant, category, and tag classifications. N26, ING, Kontist, Scalable Capital, and Trade Republic CSV imports and bank synchronization work without AI. An investment account tracks positions by ISIN alongside its cash, and reconciles both against your broker's own figures. Optional OpenRouter enrichment sends the transaction date, signed amount, currency, merchant/counterparty text, and a complete registry of editable classification choices through restrictive private routing.
Imported bank facts are separate from editable merchant, category, and tag classifications. N26, ING, Kontist, Scalable Capital, and Trade Republic CSV imports and bank synchronization work without AI. An investment account tracks positions by ISIN alongside its cash, values them from a daily price feed that needs no key, and reconciles both against your broker's own figures. Optional OpenRouter enrichment sends the transaction date, signed amount, currency, merchant/counterparty text, and a complete registry of editable classification choices through restrictive private routing.
> **There is no application login.** Keep Finance Duck behind your VPN. The default service and Docker Compose port bindings are loopback-only. Setting a hostname does not provide authentication or firewall protection.
@@ -206,9 +206,21 @@ Only `Executed` rows import from Scalable: a cancelled retry is all zeros, so it
Securities are registered by **ISIN** in **Instruments**. The ISIN is the identity; the name is editable display text, because one ISIN appears under several broker names over the years. Crypto is held under the ISIN-shaped identifier the broker issues for it. Set the account's **settlement IBAN** for an export that names no counterparty of its own, so deposits from your bank pair with the funding account instead of staying unpaired. They never become income either way — a broker record is excluded from spending and income analytics, from bulk reclassification, and from the AI entirely.
**Verify it yourself.** **Wealth** shows each account's cash balance, its positions as exact share counts, and named checks — row arithmetic, cash never negative, holdings never negative. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
## Value what you hold
Deliberately **not** included: market prices, market value, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis.
Positions are share counts until they have a price. Give an instrument a **market symbol** in **Instruments**`EUNL.DE`, `VWCE.DE` — and a daily job fetches its last close, so **Wealth** and the dashboard report cash **plus** market value.
One ISIN lists on several exchanges in different currencies, and the wrong listing misstates your wealth, so the symbol is chosen once by hand and confirmed by the app: a quote whose currency differs from the instrument's is **refused, not stored**. The price provider is a public, unauthenticated endpoint, and no key is needed.
- An instrument with **no symbol** is counted as unpriced, named in a check, and left out of the total. Valuing it at cost would report a number the journal cannot support.
- A symbol that fails to price **keeps its last quote** rather than losing it; every figure carries the day it is from, so the failure mode is stale, never wrong.
- Changing a symbol **discards the old quote**: a price from the previous listing values the holding on the wrong market.
- **Refresh prices** on the Wealth page runs the job immediately and reports what it did. Quotes are journal entries like everything else, so a backup restores them.
- A quote is rounded to seven significant digits, which is what a 32-bit float carries: the provider returns `165.26` as `165.25999450683594`, and keeping the eighth digit would print that noise as a price.
**Verify it yourself.** **Wealth** shows each account's cash, its positions as exact share counts, each holding's quote, value and result, and named checks — row arithmetic, cash never negative, holdings never negative, holdings priced. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
Deliberately **not** included: intraday prices, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis, and *result* is value plus everything returned less everything put in — the outcome to date, not a taxable gain.
## Deployment options