MAERMIN is a fully client-side multi-asset portfolio tracker. It ships two ways from the same source:
- Web (GitHub Pages / any static host) —
index.htmlloads plain global scripts;build.mjsconcatenates + minifies them intodist/. - Desktop (Electron) —
main.js+preload.jswrap the same UI.
There is no backend and no database. State lives in the browser's
localStorage; an optional Cloudflare Worker only proxies market data and an
opt-in, zero-knowledge encrypted sync blob.
Every module is a self-contained IIFE that attaches a single global:
(function () {
'use strict';
/* ... */
var api = { /* ... */ };
if (typeof window !== 'undefined') window.MaerminX = api; // browser
if (typeof module !== 'undefined' && module.exports) module.exports = api; // node tests
})();This dual export is what lets the pure logic be unit-tested under Node
(test/*.test.js) without a browser or bundler. UI is built with React 18 via
CDN using React.createElement (no JSX, no TypeScript).
Scripts load in a fixed order in index.html; build.mjs reads that order, so
adding a module = adding one <script> tag (and it's automatically bundled).
audit-log.js → crypto-vault.js → storage.js → migrations.js → auth.js
→ utils / engines → dividend / metadata → views (features*) → renderer.js
auth.js gates the React mount: renderer.js awaits MaerminAuth.whenUnlocked()
before rendering, so the app always reads decrypted data. Schema migrations run
at mount (post-unlock) via MaerminMigrations.run().
| Module | Global | Responsibility |
|---|---|---|
crypto-vault.js |
MaerminVault |
AES-256-GCM; PBKDF2-600k or Argon2id KDF; wrap-check (no password stored); HKDF sub-keys; idle auto-lock; WebAuthn-PRF passkeys; printable recovery-code kit — a second wrapping of the vault key (enrollRecovery/unlockWithRecovery), the code is shown once and never stored/transmitted |
storage.js |
MaerminStorage |
Transparent encryption-at-rest shim over localStorage for a fixed set of sensitive keys; reversible plaintext backup; portable encrypted backup export/import |
auth.js |
MaerminAuth |
Setup / unlock / lock UI; mount gate; change-password; one-time recovery-code reveal at setup + recovery-code unlock path |
audit-log.js |
MaerminAuditLog |
On-device event + error trail (non-sensitive, ring-buffered) |
migrations.js |
MaerminMigrations |
Versioned, idempotent localStorage migrations |
Transactions are the source of truth:
transaction = { id, type(buy|sell|dividend|interest|…), category(crypto|stocks|skins|commodities),
symbol, symbolName?, quantity, price, fees?, currency(EUR|USD), date, portfolioId, notes? }
Everything else is derived from transactions (single source of truth in
metrics.js):
MaerminMetrics.buildPositions(transactions, {exchangeRate})→ grouped{crypto,stocks,skins,commodities}with EUR cost basis (USD converted, sells reduce basis proportionally).MaerminMetrics.computeStats(portfolio, prices)→ value / invested / P&L.- Net worth, FIRE, concentration, drift, currency exposure, tax-loss harvest.
The canonical internal currency is EUR; USD inputs (CS2 skins, some stocks)
are converted via the live USD→EUR rate (MaerminUtils.toEUR). Display
conversion happens only at format time (formatPrice).
| Module | Global | Purpose |
|---|---|---|
ticker-validation.js |
MaerminTickers |
Normalise symbols (e.g. BRK.B→BRK-B, keep SAP.DE) |
equity-metadata.js |
MaerminEquityMeta |
Sector/country per ticker — resolves via the Worker's action=profile (Yahoo assetProfile, no FMP key needed; falls back to the FMP profile API if a key is set) + 30-day cache + an expanded static map. Yahoo's sector/country labels are normalised to the app's GICS-style buckets. Powers the Strategy tab's Sector & Country allocation |
dividend-data-service.js |
DividendDataService |
Dividend data, FIFO forecast, calendar. Resolves per-symbol dividends via the Worker's action=fundamentals (Yahoo — fetchDividendFromWorker, no FMP key needed; frequency inferred from dividendRate / lastDividendValue), falling back to the FMP API (key) then the ~31-ticker built-in DB. Symbols are normalised through MaerminTickers.normalizeForDividends, which also applies renamed-ticker aliases (FISV→FI, FB→META, …). buildPaymentSchedule(portfolio, {months, back}) expands resolved holdings into one dated entry per individual payout across a trailing back window (already-received, flagged past) plus the next months — the Dividend Calendar auto-derives these (read-only; upcoming blue, received grey) and merges them with the user's manual entries, so every payer shows who/when/how-much and the yearly total reconciles received + upcoming |
tax-report-builder.js |
MaerminTaxReport |
Per-lot FIFO tax report + PDF/Excel; for jurisdiction de integrates the GermanTax detail (summary.germanDetail, injectable via opts.germanTax for Node tests) |
tax-settings.js |
MaerminTaxSettings |
User-editable tax parameters (Abgeltung rate, Soli toggle, church tax, Freistellungsauftrag, crypto exemption, Teilfreistellung overrides) + per-position manual taxable overrides (sensitive). Pure sanitize/computeAbgeltung/teilfreistellungRate/positionOverride; the engine reads these and falls back to statutory defaults. Tested in test/tax-settings.test.js |
tax-calculation-engine.js |
TaxCalculationEngine (+ GermanTax) |
Jurisdiction tax estimates; GermanTax adds the pure German fund-taxation depth: Vorabpauschale (BMF Basiszins table + overrides, month pro-rating, sale credit), Teilfreistellung per fund type (symmetric on losses), statutory order Teilfreistellung -> Verrechnung -> Sparerpauschbetrag -> Abgeltungsteuer/Soli/Kirchensteuer (sec. 32d formula), crypto Freigrenze. Dual-exported, tested in test/german-tax.test.js |
allocation.js |
MaerminAllocation |
Asset-class allocation + drill-down |
projection.js |
MaerminProjection |
Multi-scenario wealth projection |
recurring.js |
MaerminRecurring |
Recurring liabilities (loans/mortgages) |
portfolio-analytics.js |
MaerminAnalytics |
Pure quant fed return series: benchmarks (α/β/Tracking Error/Information Ratio/R²), simulator (future value/FIRE/withdrawal/Monte-Carlo success), risk (max drawdown/rolling vol/Fama-French OLS) |
analytics-data.js |
MaerminAnalyticsData |
Bridge: builds a portfolio value path + aligned period returns from built positions + per-symbol priceHistory (incl. N-series alignReturns/subtract for factor construction), the inputs MaerminAnalytics expects |
etf-lookthrough.js |
MaerminLookThrough |
ETF/fund X-Ray: pure analyze() over position rows + per-fund holdings → effective per-security exposure, sector/country/currency look-through, fund-overlap pairs, hidden concentrations. Holdings come from the Worker fundholdings route via the shared, session-cached loadFundData loader (injectable fetch), merged with a static snapshot of common ETFs (graceful degradation) |
cost-analysis.js |
MaerminCostAnalysis |
Ongoing costs (TER): pure buildFundRows/computeOngoingCosts/projectCostDrag → annual EUR cost per fund, total drag p.a., weighted average TER, multi-year cumulative cost-drag projection. TER from the same loadFundData plumbing (override > worker > snapshot); manual overrides in localStorage (symbols + ratios only, not sensitive) |
dividend-quality.js |
MaerminDividendQuality |
Dividend quality & safety: pure scorePosition (heuristic 0-100 from payout ratio, growth streak, dividend growth, yield sanity; cut-risk flag; weight redistribution when fundamentals are missing), scorePortfolio (income-weighted health + income-at-risk share), cagrFromSeries. Fundamentals from the Worker fundamentals route; degrades to the DividendDataService history heuristic |
risk-monitor.js |
MaerminRiskMonitor |
Continuous structural risk alerts: pure evaluate (five rules - position concentration, effective look-through concentration, allocation drift, drawdown, volatility - against user thresholds, percent-based) + shouldNotify (cooldown dedupe; recovery re-arms a rule). Inputs gathered from MaerminMetrics / MaerminAnalyticsData / MaerminAnalytics / MaerminLookThrough - no new quant. Notifies via MaerminPWA; thresholds + notify state in localStorage (ids/percents/timestamps, not sensitive) |
fx-attribution.js |
MaerminFxAttribution |
FX attribution: pure multiplicative decomposition (1+r_EUR) = (1+r_local)(1+r_fx) per position over the common tail with the EUR/USD path (existing Worker yf route, EURUSD=X inverted), value-weighted portfolio aggregation + per-currency contributions. Position currencies mirror computeCurrencyExposure exactly |
backtester.js |
MaerminBacktester |
Allocation backtester: pure unit-holding simulation over aligned real price series (optional monthly/quarterly/yearly rebalancing on a trading-day grid); metrics (CAGR, max drawdown magnitude, volatility) reused from MaerminAnalytics; history over the existing Worker yf route; folds into the Monte-Carlo tab |
savings-plan-executor.js |
MaerminSavingsExecutor |
Savings-plan auto-execution: calendar-exact schedules REUSED from MaerminRecurring.expandOccurrences (replaces the 30.44-day approximation), idempotency via on-transaction markers (source:'savings-plan', planId, dueDate - survive reload/backup/sync), post-merge dedupeExecutions with a deterministic survivor, quantity = amount / price at the due date (price-history lookup; unresolvable -> pending, never guessed). Catch-up runs on app open/unlock, never in the background |
options-engine.js |
MaerminOptions |
Options/derivatives tracking: pure buildOptionPositions (signed-contract book over category: 'options' transactions with underlying/optionType/strike/expiry/contractSize; premiums EUR at ingestion), positionMetrics (intrinsic-only valuation, moneyness, days-to-expiry; strike converts at the current rate), computeStats, validateOptionTx. buildPositions ignores the category by design, so the shared positions/stats engine is never bent |
renderer.js holds the InvestmentTracker component: all state, the nav, the
view router (renderView() switch), and a ViewErrorBoundary so a single view
crash shows a recoverable fallback instead of a blank app. Feature views live in
features.js … features7.js and investment-views.js, rendered by passing
already-computed numbers down as props (no view computes cross-cutting metrics
itself — it reuses MaerminMetrics).
First-run onboarding (onboarding.js → MaerminOnboarding) is a view-layer
module with a React Wizard plus dual-exported pure logic: endpoints() builds a
cheap probe per data source, classify() maps each probe outcome to green/amber/red,
and probe()/probeAll() run them with an injectable fetch (so the connection test
is unit-tested under Node). The wizard offers a guided Worker deploy (one-click Copy
worker.js), the live connection test, and a Demo-mode entry; renderer.js opens it on
first run and from API Settings, and surfaces a recovery-code nudge for vaults created
before recovery codes existed.
Analytics fold-in (no new tabs): the MaerminAnalytics engine is surfaced through
thin view modules — simulator-view.js (MaerminSimulatorView, Future Value/FIRE/
Withdrawal/Monte-Carlo) folds into the Monte-Carlo analytics tab, and
analytics-views.js (MaerminAnalyticsViews) adds a benchmark overlay (α/β/TE/IR/R²,
fetching a proxy via the worker yf endpoint) to the Returns view plus a rolling
volatility/return panel and a Fama-French factor-exposure panel (MKT/SMB/HML loadings
- annualised alpha, regressing the portfolio on ETF-proxy factor returns — VTI; IWM−IWB;
IWD−IWF — over the same
yfendpoint) to the Risk view. The proxy/diff alignment is pure and unit-tested (MaerminAnalyticsData.alignReturns/subtract); the engine'sfactorExposureOLS stays the single source of truth. The AI advisor's findings (advisor.js→MaerminAdvisor.Panel) fold into the Health view.
ETF look-through (X-Ray) (etf-lookthrough.js → MaerminLookThrough) folds into
two existing views (no new tab): the Health view gets effective per-security
exposure (direct + through funds) plus sector/country/currency look-through, and the
Risk view gets fund-overlap pairs + hidden concentration findings (Panel with
mode: 'overview' | 'risk'). The whole computation (analyze) and the data plumbing
(parseHoldingsResponse, mergeFundData, fallbackHoldings, positionRows) are
pure and Node-tested; the Panel is a thin fetch shell over the Worker's
action=fundholdings route, gated exactly like Discovery — an older Worker (400/404)
triggers an upgrade note while a built-in approximate snapshot of common index ETFs
keeps the feature useful. The Health panel hands its result up so
MaerminAdvisor.analyzeFromMetrics can rank hidden fund concentrations alongside its
other findings (bundle.lookThrough). Nothing is persisted (no new SENSITIVE_KEYS).
Ongoing costs (TER) (cost-analysis.js → MaerminCostAnalysis.OngoingCostsPanel)
folds into the Fee Analyzer view below the transaction-fee breakdown (no new tab):
annual EUR cost per fund position, total ongoing drag p.a., weighted average TER, and a
multi-year projection of the cumulative cost drag, plus an inline manual TER override
per fund. It reuses the X-Ray's loadFundData loader (same gating/degradation), so
there is exactly one fund-data pipeline; only the override map (symbol → expense ratio,
no amounts) is persisted in localStorage and is not sensitive.
Dividend quality & safety (dividend-quality.js → MaerminDividendQuality.QualityPanel)
folds into the Dividends view below the calendar/forecast tabs (no new tab): a
per-payer table (income, payout, streak, growth, coverage, safety score with a
cut-risk badge), a click-to-open reasoning detail per row, and the aggregated
portfolio dividend-health KPIs. Payers and history metrics come from the ONE existing
DividendDataService; fundamentals (payout ratio, EPS) come from the Worker
fundamentals route with the usual gating — an older Worker or no Worker URL means
the score is computed from history alone and the panel says so. The score is labelled
a heuristic; nothing is persisted.
Risk & drift monitor (risk-monitor.js → MaerminRiskMonitor) folds into the
Alerts view below the price alerts (no new tab): a rule-status table (current value
vs limit, breach state) with inline threshold editing, a notification toggle (wired to
MaerminPWA.requestNotifications/notify) and a cooldown. The continuous part is one
renderer useEffect on prices: checkAndNotify re-evaluates the rules on every
price refresh regardless of the active view, dedupes via the cooldown state (a
recovered rule re-arms immediately), and raises local PWA notifications only when the
user opted in. Drawdown/volatility breaches additionally surface as advisor findings in
Health (bundle.riskMonitor); concentration/drift/look-through breaches are not
duplicated there because the advisor already covers those dimensions.
German fund taxation (german-tax-view.js → MaerminGermanTaxView.Panel) folds
into the Tax view when the jurisdiction is Germany (no new tab): fund-type
classification per position (Teilfreistellung), a Vorabpauschale worksheet per
accumulating fund and tax year (values prefilled from the local price history at the
year boundaries via the pure priceAt/qtyAt/prefillRow helpers, everything
editable), Basiszins and church-tax settings, and the integrated ordered computation
from MaerminTaxReport.build (summary.germanDetail, also exported to PDF/Excel).
Saved Vorabpauschalen credit later sales of the same fund. All stored inputs
(maermin_fund_types, maermin_vap_records, maermin_kirchensteuer) are registered
in SENSITIVE_KEYS (the type map reveals held symbols, records hold EUR amounts, the
church-tax rate reveals a religious affiliation); the Basiszins overrides are public
BMF rates and stay plain. Labelled a helper computation, not tax advice.
Broker PDF import (pdf-import.js → MaerminPdfImport) adds settlement-PDF
parsing to the existing Broker Import wizard (no new tab, no second import engine):
pdf.js extracts the text fully client-side (both CDN files version-pinned + SRI,
lazy-loaded on first use; the worker file loads as a plain script so pdf.js runs its
main-thread fallback — worker-src CSP untouched), a pure generic German-settlement
parser with per-broker configs (Trade Republic, Scalable, ING, DKB, Comdirect) turns
the text into candidate transactions, and candidatesToCSV feeds them into the
existing MaerminImportMapping preview/commit flow — editable mapping, row errors and
duplicate detection come for free. Number/date parsing is reused from that module.
Parsers are best-effort against frozen text fixtures (test/pdf-import.test.js);
missing fields are reported per file, never guessed. The PDF never leaves the device.
Share & Compare (share-snapshot.js -> MaerminShare) is the sanctioned new
surface of round 2 (Roadmap Epic 9, done without breaking zero-knowledge): an opt-in
view under Tools that shares a REDACTED snapshot - percentage weights by asset class /
sector / region / currency plus optional scores, never absolute values, quantities or
symbols. Redaction is enforced three times (pure buildSnapshot only computes
percentages; validateSnapshot rebuilds against a hard allowlist before the wire; the
Worker validates again server-side), and test/share-snapshot.test.js contains the
leak proof that distinctive absolute inputs never appear in the serialised snapshot.
The Worker share route stores snapshots under random ids with a 90-day TTL in the
sync KV namespace and keeps a count+sum aggregate for the anonymous benchmark.
Old Workers degrade with the upgrade note.
Options tracking (options-engine.js → MaerminOptions) extends the transaction
model additively: category: 'options' rows carry underlying, optionType
(call/put), strike, expiry and contractSize next to the usual fields
(quantity = contracts, price = premium per share); the transaction modal grows the
matching inputs and derives tx.symbol from the contract (AAPL 2026-12-18 C 150).
Because buildPositions ignores unknown categories by design, the shared
positions/stats/tax engines are untouched — the options book is a separate pure
reader over the same transaction list (signed contracts: long/short/closed, net
premium, intrinsic-only valuation against the existing EUR price map). The Panel
folds into the Overview below the positions table and renders nothing when no
option transactions exist. The UI states that valuation is intrinsic-only (no time
value/Greeks) and that options are excluded from portfolio value and tax figures. All consume
MaerminAnalyticsData for series construction, so no view recomputes quant. A
Security & Sync settings modal (in renderer.js) exposes MaerminAuth.getStatus()
(encryption-at-rest, KDF, auto-lock, passkey, recovery code) and MaerminSync
(zero-knowledge cloud sync) status + actions.
Asset Discovery (discovery.js → MaerminDiscovery) is the one sanctioned new
surface (Roadmap P5) — a read-only screener for ETFs/stocks/crypto, top movers, and a
dividend screener, reached from the Tools nav. The Worker is the single data source:
a new action=screener endpoint proxies + normalises Yahoo Finance in two modes —
scrId= (predefined screener / movers) and symbols= (batch quote, used for the curated
dividend universe). Everything testable without a browser — parseResponse, EUR
conversion at ingestion (toEURRow, reusing MaerminUtils.toEUR), applyFilters,
sortRows, dividendScreen, buildUrl — is pure and Node-tested; the React View is a
thin shell over them. It is fully gated: with no Worker URL, or against a Worker that
predates the endpoint (400/404), it shows a clear upgrade note instead of breaking. No
data is persisted and nothing sensitive is sent, so it adds no SENSITIVE_KEYS.
Portfolio Intelligence (portfolio-intelligence.js → MaerminIntelligence) is the
Feature-1 (v10) surface under Discover & Tools (one new view, reached from the
sidebar hub + command palette g i). It is an aggregator, not a new analytics engine:
the pure analyzeFromInputs(inputs, opts) runs ten structural checks — single-company
(effective/look-through), hidden concentration, sector overexposure, country risk,
currency risk, correlation clusters (fund overlap), style drift (sector tilt vs a broad
market reference), dividend trap, yield trap and liquidity risk — and ranks every finding
into the spec's three priorities (critical → important → optimization) with a concrete
recommendation per finding. gatherInputs feeds it from the existing single sources of
truth only: MaerminLookThrough.analyze() (the renderer reuses its already-fetched
lookThroughResult from Health) for effective/sector/country/currency exposure and fund
overlap, MaerminMetrics for the direct-concentration/currency/liquidity fallback when
fund data has not loaded yet, and the one DividendDataService for the dividend/yield-trap
signals. Nothing is persisted (no new SENSITIVE_KEYS, no migration — findings are derived
on every render); toExport() yields a redaction-safe finding list (advice text +
percentages, never amounts) for the Professional-Reports feature. The pure layer is
Node-tested (test/portfolio-intelligence.test.js) and the View is registered in
smoke-views. Accessibility: the priority chips and finding list use role="list"/
listitem with aria-labels and aria-hidden status glyphs.
v10 feature stores. Three new pure modules add persisted user data and are
therefore wired into the full-vault backup (backup-engine.js KEYS):
- Portfolio Value Snapshots (
portfolio-snapshots.js→MaerminSnapshots, keymaermin_snapshots). An append-only daily series of total value, one point per(day, portfolioId)(a same-day re-record overwrites that day, capped per portfolio so it can't bloat). PureaddPoint/changeBetween/valueAsOf/prune; the renderer records it from a single best-effort top-level effect that reuses the Overview's value aggregation (never throws into a render, never records in demo mode). This is the ground-truth value history when no external (Yahoo) history is reachable. - Smart Tags (
tags.js→MaerminTags, keymaermin_tags). Case-insensitively-unique labels referencing UPPERCASEd symbols, orthogonal to asset category.aggregate(state, positions)is pure and takes the caller's already-priced positions, so it never needs the metrics engine. Weights are share of the supplied total so untagged value stays visible. - Custom Dashboard Layout (
dashboard-layout.js→MaerminDashboard, keymaermin_dashboard_layout). An ordered list of{id, visible};normalize(raw, available)reconciles a saved layout against the widgets a build actually ships — new ids append visible, removed ids drop, the rest keep their order/visibility — so a new card never corrupts an old saved layout.
All three are dual-export IIFEs with headless Node tests (test/portfolio-snapshots.test.js,
test/tags.test.js, test/dashboard-layout.test.js). They store non-secret data only, so
they add no SENSITIVE_KEYS and need no migration.
v10.x roadmap modules build on top of the v10 stores:
- Snapshot-Powered Performance (
performance-cards.js→MaerminPerformance) is a pure consumer of the snapshot series — no new persistence.computeAll(series, asOf)derives the 1D/1W/1M/3M/6M/YTD/1Y/Max cards with carry-forward (valueAsOf) and apartialflag when the look-back predates inception, so a young portfolio still shows a real number. The core takes a plain[{d,v}]array (Node-tested,test/performance-cards.test.js);cards(portfolioId)bridges toMaerminSnapshotsin the app. - Rebalancing Planner (
rebalancing-planner.js→MaerminRebalance, keymaermin_rebalance_targets, in the backup) persists target weights by category or tag basis. The pureplan(state, actual, {band})is basis-agnostic — the caller passes the current allocation as[{key,value}](usegroupByfor the category basis, orMaerminTags.aggregate(...).rowsfor the tag basis) — and returns per-bucket drift (in percentage points) plus the concrete buy(+)/sell(−) delta to reach target within a tolerance band, with a turnover/balancedsummary. Node-tested intest/rebalancing-planner.test.js.
npm run build:web—build.mjsreadsindex.html, concatenates local scripts in order, minifies with esbuild, emitsdist/(+ PWA assets). CDN scripts keep their SRI hashes.npm test—test/run-all.mjsruns everytest/*.test.jsin a child process and fails on any non-zero exit. Tests exercise the real modules via the Node export, with minimal DOM/crypto stubs.npm run check—node --checkon every JS file (fast syntax gate).
CI (.github/workflows/node.js.yml) runs build:web + test on Node 18/20/22.
- Integrate, don't accrete — extend existing views/cards/charts; avoid new tabs/engines. One metrics service, one allocation engine, etc.
- Pure logic is testable — keep calculations in dual-export modules with
unit tests; keep
React.createElementUI thin. - Security by default — sensitive data encrypted at rest; password never stored; no remote telemetry.