Skip to content

Latest commit

 

History

History
333 lines (281 loc) · 25.7 KB

File metadata and controls

333 lines (281 loc) · 25.7 KB

MAERMIN — Architecture

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.html loads plain global scripts; build.mjs concatenates + minifies them into dist/.
  • Desktop (Electron) — main.js + preload.js wrap 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.


Module pattern

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).


Load order (security first)

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().


Security layer

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

Domain / data model

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).

Engines

Module Global Purpose
ticker-validation.js MaerminTickers Normalise symbols (e.g. BRK.BBRK-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

UI

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.jsMaerminOnboarding) 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 yf endpoint) to the Risk view. The proxy/diff alignment is pure and unit-tested (MaerminAnalyticsData.alignReturns/subtract); the engine's factorExposure OLS stays the single source of truth. The AI advisor's findings (advisor.jsMaerminAdvisor.Panel) fold into the Health view.

ETF look-through (X-Ray) (etf-lookthrough.jsMaerminLookThrough) 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.jsMaerminCostAnalysis.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.jsMaerminDividendQuality.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.jsMaerminRiskMonitor) 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.jsMaerminGermanTaxView.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.jsMaerminPdfImport) 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.jsMaerminOptions) 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.jsMaerminDiscovery) 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.jsMaerminIntelligence) 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.jsMaerminSnapshots, key maermin_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). Pure addPoint/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.jsMaerminTags, key maermin_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.jsMaerminDashboard, key maermin_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.jsMaerminPerformance) 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 a partial flag 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 to MaerminSnapshots in the app.
  • Rebalancing Planner (rebalancing-planner.jsMaerminRebalance, key maermin_rebalance_targets, in the backup) persists target weights by category or tag basis. The pure plan(state, actual, {band}) is basis-agnostic — the caller passes the current allocation as [{key,value}] (use groupBy for the category basis, or MaerminTags.aggregate(...).rows for 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/balanced summary. Node-tested in test/rebalancing-planner.test.js.

Build & test

  • npm run build:webbuild.mjs reads index.html, concatenates local scripts in order, minifies with esbuild, emits dist/ (+ PWA assets). CDN scripts keep their SRI hashes.
  • npm testtest/run-all.mjs runs every test/*.test.js in 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 checknode --check on every JS file (fast syntax gate).

CI (.github/workflows/node.js.yml) runs build:web + test on Node 18/20/22.


Design rules (V7)

  1. Integrate, don't accrete — extend existing views/cards/charts; avoid new tabs/engines. One metrics service, one allocation engine, etc.
  2. Pure logic is testable — keep calculations in dual-export modules with unit tests; keep React.createElement UI thin.
  3. Security by default — sensitive data encrypted at rest; password never stored; no remote telemetry.