Commit graph

87 commits

Author SHA1 Message Date
Viktor Barzin
5e4f83d4e7 wealth: consolidation chunk 1 — merge NW/contribution/growth, returns table, yearly combo
36 -> 19 panels (chunk 1 of 2), zero metric loss:
- 3 NW/contribution/growth timeseries -> 1 "contribution vs market value (+growth)"
- 11 returns/Δ stat cards (12mo x3 + Δ 1d/7d/30d/90d all&mkt) -> 1 "Returns over
  time windows" table (window × Δall/Δmkt/return%)
- 2 yearly barcharts -> 1 combo (contributions/market-gain bars + return-% line,
  timeFrom=10y so full history always shows)

All SQL validated live. Chunk 2 (net-pay $grain merge, projection->Trend panel,
row reorg) to follow.

[ci skip]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:27:09 +00:00
Viktor Barzin
f364399ede wealth: add 30y net-worth projection row + align net-pay panel
Implements the committed projections design (docs/plans/2026-05-28-wealth-
projections-{design,plan}.md): a collapsed "Projections" row on the wealth
dashboard with 5 template vars (rate_low/base/high, monthly_contribution=auto,
horizon_years=30), a multi-scenario projection panel (Low/Base/High + trailing-
3y historical line + a base-rate compounding-only line), 3 stat cards, and a
text panel with one-click future time-range links.

Projection is pure SQL over dav_corrected: compound + ordinary-annuity FV from
today's net worth; auto contribution = trailing-12mo run-rate (COALESCE/NULLIF
so $monthly_contribution=auto doesn't constant-fold 'auto'::numeric). Historical
rate = trailing-3-full-year geometric mean of per-year Modified-Dietz returns
(~10.4%) — all-time was a nonsense 83% because the all-accounts-complete window
is only ~4 months, and the true all-time geomean is skewed by 2021's +86%.

Also aligns "Net pay vs market gain — per month" to consecutive month-end
deltas (same fix as the other monthly panels). Verified all SQL live.

[ci skip]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:50:41 +00:00
Viktor Barzin
4f71ce6bc5 wealth: fix Fidelity Feb-2026 zero-gap + month-boundary contribution smear
Two correctness fixes to the wealth dashboard, found while validating
contribution data against actual-viktor (source of truth):

1. dav_corrected (Fix 1): LOCF gap-fill scoped to the Fidelity pension.
   A PlanViewer scrape gap left total_value=0 for 13 days from 2026-02-16,
   which cratered net worth and produced a phantom -£97,457 "contribution"
   in Feb then +£100,458 in Mar. Carry the last non-zero day forward across
   the gap (a £0 pension valuation is always a scrape gap, never real).

2. wealth.json (Fix 3): "Monthly contributions vs market gain" and "Annual
   change decomposition" now use consecutive period-end deltas instead of
   within-period first-to-last-obs, so contributions landing near a period
   boundary are no longer dropped/mis-attributed.

Verified live: Feb-2026 monthly contribution now +£34,000 (real Trading212
RSU-proceeds investment, reconciles with actual-viktor), no spurious
negatives. Brokerage contributions unchanged (already correct).

Applied via scripts/tg (wealthfolio + targeted monitoring ConfigMap).

[ci skip]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:58:59 +00:00
Viktor Barzin
58cced5dab monitoring: render market-vs-salary periodic panels as lines, not bars 2026-05-28 22:18:59 +00:00
Viktor Barzin
388a7f60c7 monitoring: add net-pay-vs-market-gains panels to wealth dashboard
Three new panels comparing employment income to investment returns over
time, via Grafana's -- Mixed -- datasource (salary lives in payslip_ingest,
portfolio in wealthfolio_sync — separate DBs, so per-target datasources):
- cumulative net take-home pay vs cumulative market gain (line race)
- net pay vs market gain per year (grouped bars)
- net pay vs market gain per month (grouped bars)

Inserted after the "Growth over time" panel; existing panels shifted down,
full-width tables remain at the bottom.
2026-05-28 22:13:44 +00:00
28db8fc9d4 fire-planner: COL refresh CronJob + Grafana Cost-of-Living dashboard
Operational layer for the new col_snapshot cache shipped in
fire-planner@e72fd22:

stacks/fire-planner:
- fire-planner-col-refresh CronJob — Sun 04:00 UTC, no-op until rows
  age toward the 1-year TTL boundary (within 7 days). Calls
  python -m fire_planner col-refresh-stale, upserts via cache.upsert.

monitoring/dashboards/cost-of-living.json (Finance folder):
- Two template variables: $city (single-select from col_snapshot),
  $baseline_city (for COL ratio computation, defaults London).
- Stat row: total w/rent, w/o rent, 1-bed rent, ratio (color-coded).
- All-cities ranked table with gradient-gauged total + colored ratio.
- Cache-freshness table flags rows approaching TTL expiry.

Initial population needs a one-shot: post-Keel-rollout,
  kubectl -n fire-planner exec deploy/fire-planner -- \\
    python -m fire_planner col-seed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:15:38 +00:00
b85a5ecebd monitoring(wealth): drop 6y timeFrom override on META vest cadence 2026-05-18 19:25:29 +00:00
0a4ce162f8 monitoring(wealth): keep only FIFO-realized PNL table; pair Positions + vest-cadence side-by-side
- Removed panel 27 (META RSU vest value over time) — superseded by
  vest-cadence chart which carries the same value signal plus the
  share-count overlay.
- Removed panel 28 (per-vest value at vest vs today) — duplicative with
  panel 31's FIFO realized PNL.
- Removed panel 29 (per-sell realized PNL) — same data as panel 31,
  just rolled up by sell date instead of vest date.
- Resized panel 26 (Positions) to w=12 and moved panel 30
  (META vest cadence) to (y=32, x=12, w=12) so they sit side-by-side
  next to the Positions table.
- Moved panel 31 (FIFO realized PNL) to y=118, where the deleted RSU
  chart used to live.
2026-05-18 19:11:13 +00:00
20018cd9b4 monitoring(wealth): per-vest realized PNL via FIFO sell-match
New table panel below the per-sell breakdown. For each vest, FIFO-match
its shares against the subsequent sells (shares from earlier vests get
sold first), and aggregate the matched portions:

  realized_pnl = SUM(matched_qty * (sell_price - vest_price))
  pnl_pct      = realized_pnl / SUM(matched_qty * vest_price) * 100
  days_held    = AVG(sell_date - vest_date) per matched portion

Footer reducer sums shares, vest value, sell value, and realized PNL
so the bottom row is the full-portfolio realized take.
2026-05-18 18:43:46 +00:00
14befe3998 monitoring(wealth): META vest cadence chart — value vs shares (dual axis)
Per-vest event line chart. Left Y axis (blue): vest value at the
time = SUM(quantity * unit_price), in USD. Right Y axis (orange):
number of shares vested. One point per vest date (aggregated when
multiple BUY rows share a date, e.g. 2021-05-18 was 18 + 2 shares).

Lets Viktor see how vest sizes ramped (initial 18 shares -> 38 ->
60s) and how the per-vest USD value tracked META's price ride
across 2020-2026. timeFrom='6y' override pins the panel to the full
vesting window.
2026-05-18 18:38:36 +00:00
3d5841a776 monitoring(wealth): META vest + sell PNL tables with FIFO cost basis
Two new bottom-of-dashboard tables:

Panel 28 'META vests — value at vest vs today': one row per BUY
activity. Shows vest-day price * shares + what those same shares
would be worth at today's META quote, plus the hypo P&L if Viktor
had held everything (color-text on the gain columns).

Panel 29 'META sells — realized PNL vs if held until today':
one row per SELL with FIFO-matched cost basis (LEAST/GREATEST
overlap in cumulative-share space). Shows realized P&L, the
counterfactual P&L had he held until today, and the
'missed by' delta = (today_price - sell_price) * shares.

Both pull today_price dynamically from quote_latest via a CTE so
they self-update as Yahoo updates the META quote. Schwab account
is empty so no live activity is expected.
2026-05-18 18:36:00 +00:00
37c7668181 monitoring(wealth): pin META RSU panel to 6y window
Dashboard default time range is now-180d, but the META vesting + sell
arc spans 2020-11 → 2026-02. With the default window the panel just
showed a flat line at $64 (the empty post-sell residual). timeFrom='6y'
override makes panel 27 always render the full vesting curve regardless
of the dashboard-level time selector.
2026-05-18 18:30:10 +00:00
2d6082c724 monitoring(wealth): META RSU vest value panel (Schwab account)
Daily total_value timeseries for the Schwab workplace account
(account_id 72d34e09-...). Single-asset account holding META RSUs
that vested 2020-11 → 2026-02 and were sold opportunistically over
the same window. Currency USD (account_currency). Yahoo quote on
META powers WF's daily mark; the historical DAV mirrored into
wealthfolio_sync via pg-sync gives us ~2k days of vesting curve.
2026-05-18 18:25:34 +00:00
2841347ec2 wealth: dav_corrected view fixes pension gains-offset miscategorisation
The broker-sync Fidelity provider emits 'unrealised-gains-offset'
DEPOSIT activities to reconcile Wealthfolio's total with the
PlanViewer reported pot, because Wealthfolio doesn't track pension
fund units directly. Wealthfolio's data model treats that DEPOSIT as
a cash contribution, which double-inflates net_contribution and
zeroes out the implied growth.

Add a Postgres view 'dav_corrected' in wealthfolio_sync that
subtracts the cumulative gains-offset from net_contribution per
account per date (re-exporting as 'net_contribution' so it's a
drop-in replacement). All 17 wealth dashboard panels that compute
contribution/growth/ROI now read from the view. Total impact:
portfolio Growth jumps from £301,753.19 to £337,474.39 (exactly
the £35,721.20 Fidelity offset that was previously miscategorised).
2026-05-16 13:43:36 +00:00
2d52b583f5 monitoring(wealth): move Positions table under contrib/growth row
Positions panel now sits at y=32 (immediately below the
contrib-vs-market + growth row at y=22..32), and everything from
the per-account stack down shifts 8 rows lower.
2026-05-14 16:58:38 +00:00
f1c56bf257 wealth: positions table panel (shares + cost basis + unrealised return)
pg-sync sidecar now mirrors three extra views from the wealthfolio
SQLite: assets (id/symbol/name/currency), quote_latest (one row per
asset, preferring YAHOO over MANUAL on same-day collisions), and
positions_latest (currently-held positions extracted from the TOTAL
aggregate row of holdings_snapshots — quantity, average cost,
total cost basis).

Wealth dashboard gets a new bottom Positions table joining the three:
symbol, name, shares, avg cost, last price, market value, cost,
gain, return %. Gain and return % are color-text with red<0, green>=0
thresholds.
2026-05-14 16:01:27 +00:00
5754cfb56f monitoring(wealth): paint declining segments red on growth chart
Mirror the panel 5 treatment on panel 7 (Growth = market value −
contribution). Second SQL column emits the growth value only when
the point is part of a declining segment; field override paints it
red with no fill, spanNulls=false.
2026-05-12 19:22:43 +00:00
858350fa93 monitoring(wealth): paint declining segments red on portfolio chart
Add a second SQL column on panel 5 that returns net_worth only when the
current point's previous or next neighbor is lower — i.e. the point is
part of a declining segment (including the peak and trough endpoints).
A field override draws this 'decline' series in red with no fill and
spanNulls=false, overlaying the green base line so down periods show
up as red on top of the climb.
2026-05-11 20:13:16 +00:00
Viktor Barzin
4326f09baa monitoring(wealth): monthly contrib-vs-mkt as line chart, not bars
User asked for two lines instead of side-by-side bars at monthly
granularity. Converts panel 25 from barchart to timeseries:

  * type: barchart -> timeseries
  * format: table -> time_series, SELECT month::timestamp AS time
  * drawStyle line, lineWidth 2, fillOpacity 0, showPoints auto
  * Same blue (contributions) / green (market gain) colour overrides

Where the green line rises above the blue line is the visual cue that
the market out-earned new contributions for that month -- the trend
the user wants to track.

Diff is small (15 ins / 28 del) because the bar-chart-only fields
(barRadius, barWidth, groupWidth, stacking, xField, xTickLabelRotation)
are dropped.
2026-05-07 22:41:46 +00:00
Viktor Barzin
a2e9a0fecf monitoring(wealth): monthly contributions vs market gain bar chart
Goal stated by user: see when monthly market gain starts to exceed
monthly contributions, i.e. the inflection point where the market is
out-earning savings rather than the other way around.

New panel id=25 between the annual decomposition (13) and per-account
ROI (14): bar chart with two side-by-side bars per month --
contributions (blue) and market gain (green). Same calculation as
panel 13 but month-grain instead of year-grain. Months where the
green bar dwarfs the blue one are visible at a glance.

SQL: same endpoints CTE pattern as panel 13, with date_trunc('month',
valuation_date) as the grouping key. Uses max_complete cutoff so
partial-today doesn't skew the latest month.

Layout: panels at y >= 75 shifted down by 11 (chart height). New
chart at y=75; panel 14 (per-account ROI) -> y=86; panel 10
(activity log) -> y=96.

Spot check (recent months from PG):
  2025-07: contrib +£5,601    market +£42,295   <- big market month
  2025-09: contrib +£1,501    market +£24,206
  2026-02: contrib +£35,501   market +£41,382
  2026-03: contrib +£5,501    market -£38,483   <- correction
  2026-04: contrib +£73,267   market +£21,448
2026-05-07 22:38:31 +00:00
Viktor Barzin
ae70faf8be openclaw: realtime usage dashboard via Prometheus exporter sidecar
Stdlib-only Python exporter ($1) reads ~/.openclaw/agents/*/sessions/*.jsonl
(assistant messages with usage) plus auth-profiles.json (OAuth expiry,
Plus-tier label) and exposes Prometheus text format on :9099/metrics.
Container is python:3.12-slim; pod template gets prometheus.io/scrape
annotations so the existing kubernetes-pods job picks it up — no
ServiceMonitor needed.

Metrics exported:
  openclaw_codex_messages_total{provider,model,session_kind}    counter
  openclaw_codex_input/output/cache_read/cache_write_tokens_total
  openclaw_codex_message_errors_total{reason}
  openclaw_codex_active_sessions{kind}                          gauge
  openclaw_codex_oauth_expiry_seconds{provider,account,plan}    gauge
  openclaw_codex_last_run_timestamp                             gauge

Grafana dashboard "OpenClaw — Codex Usage" (Applications folder, 30s
refresh): messages/5h vs Plus rate-card, % of 1,200 floor, tokens/5h,
cache hit %, OAuth expiry days, active sessions, last-turn age, errors,
plus per-model timeseries + bar gauge + error table.

Plus rate-card thresholds in the gauge are conservative (1,200/5h floor;
real cap is dynamic 1,200–7,000). Re-baseline if throttling shows up
below 80%.
2026-05-07 09:04:25 +00:00
Viktor Barzin
2d236dc48e monitoring(wealth): delta panels to 2x4 grid (rows = type, cols = window)
Better visual grouping: instead of 8 paired panels in a single row at
w=3 (cramped, hard to scan), arrange as a 2x4 grid at w=6. Top row
("all" — wealth change incl new money), bottom row ("mkt" — pure
market gain). Columns are timeframes 1d / 7d / 30d / 90d.

Reading vertically: same window, two interpretations side by side.
Reading horizontally: same metric across timeframes.

Layout shift: delta row goes from y=4 (4 wide) to y=4..11 (8 high).
All chart/log panels with y >= 8 shift down by another 4 rows
(net-worth chart 8->12, activity log 81->85, etc.).
2026-05-06 20:29:27 +00:00
Viktor Barzin
e383bcd7fb monitoring(wealth): pair every delta panel with market-only twin
User feedback: net-worth delta panels (1d/7d/30d/90d) confused
because +£174k over 90d looked too big against the £271k cumulative
unrealised gain. Decomposition showed the 90d delta was £114k of new
money in (contributions) + £60k of actual market gain.

So now the delta row shows BOTH:
  Δ Nd (all)  — net-worth change incl new money (the original number)
  Δ Nd (mkt)  — pure market gain, contributions stripped out

Pattern for "(mkt)" panels: same now_snap / past_snap CTEs but
selecting both total_value and net_contribution, then computing
(nw_delta - contrib_delta) = market_gain over window.

Layout: 8 panels at w=3 each on the y=4 row, paired by window
(all next to mkt for each timeframe), so you can see "wealth
change vs investment performance" at a glance.

Verified live (90d): all=+£174,612, mkt=+£60,343, contrib=+£114,268.
2026-05-06 20:25:33 +00:00
Viktor Barzin
941c794678 monitoring(wealth): add delta row (1d / 7d / 30d / 90d net-worth changes)
New row at y=4 with 4 stat panels showing net-worth change over the
trailing windows. Each uses the latest-per-account stitching pattern
(skew-resilient against partial-day syncs) and computes:

  delta = SUM(latest per account) - SUM(latest per account at or
                                       before max_complete - N)

Where max_complete is the most recent date all accounts have a row.
For each window: 1d, 7d, 30d, 90d.

Verified live values: +£8,575 / +£22,696 / +£144,633 / +£174,612.

All panels at y >= 4 shifted down by 4 rows to make room (Net worth
chart 4->8, Per-account stacked 24->28, Activity log 77->81, etc.).

Note: this commit also reformats the dashboard JSON from compact-
object form to indented form (json.dump indent=2 side effect from the
Python patch script). No semantic changes outside the new panels and
y-shifts.
2026-05-06 20:16:31 +00:00
Viktor Barzin
1cb2bb30f7 monitoring(wealth): show pre-2024 historical data on timeseries
Bug: timeseries panels were empty before 2024-04-10. Cause was the
complete_dates CTE filtering to "every active account has a row for
this date" -- which excluded every day before the most-recently-added
account first appeared. The 6th account (Trading212 Invest GIA) only
started 2024-04-10, so 4 years of legitimate historical data
(2020-06-07 onwards, when the user genuinely had fewer accounts) got
hidden.

New pattern across panels 5/6/7/8/9/12/13: replace complete_dates with
max_complete cutoff. Compute the most-recent date where all current
accounts have a row, then include every historical date up to and
including that day. Partial-today is still excluded automatically.
Historical days with fewer accounts now show as their actual smaller
sums -- which is the correct historical net worth at the time.

Verified via PG: new pattern returns 2,159 distinct days from
2020-06-07 to 2026-05-05 (vs the previous 391 from 2024-04-10).

Per-account first-seen dates:
  InvestEngine ISA       - 2020-06-07
  Schwab US workplace    - 2020-11-17
  InvestEngine GIA       - 2022-03-17
  Fidelity UK Pension    - 2022-05-16
  Trading212 ISA         - 2024-04-08
  Trading212 Invest GIA  - 2024-04-10  (was the bottleneck)
2026-05-05 18:43:26 +00:00
Viktor Barzin
6715cdc51f monitoring(wealth): re-add milestone annotations (now that PG creds rotated)
Re-applies the milestone annotation commit reverted in 0ef36aec. The
earlier "nothing loads / syntax error" was a red herring: Vault had
rotated the wealthfolio_sync DB password 7 days prior, the K8s Secret
picked it up automatically (pg-sync sidecar still working), but the
Grafana datasource ConfigMap is baked at TF-apply time so Grafana was
sending the old password. Every panel + the new annotation alike
failed with: pq password authentication failed for user wealthfolio_sync.

Fix today: refresh the datasource ConfigMap and roll Grafana.

  scripts/tg apply -target=kubernetes_config_map.grafana_wealth_datasource
  kubectl -n monitoring rollout restart deploy/grafana

Annotation source verified live via /api/ds/query: SQL returns 5
milestone rows correctly. Dashboard charts now show vertical dashed
lines at GBP100k 2021-11-01, GBP250k 2023-07-18, GBP500k 2024-09-19,
GBP750k 2025-08-26, GBP1M 2026-04-18.

KNOWN FOLLOW-UP: Vault rotates pg-wealthfolio-sync every 7 days
(static role). Todays failure will recur unless the Grafana
datasource auto-refreshes. Options:
  1. Annotate Grafana deploy with stakater/reloader so it restarts
     when wealthfolio-sync-db-creds Secret changes.
  2. Switch datasource provisioning to read password from an env var
     sourced from the Secret instead of baking into the ConfigMap.
     Combined with reloader, picks up rotation cleanly.
2026-05-02 20:27:21 +00:00
Viktor Barzin
0ef36aec36 Revert "monitoring(wealth): milestone annotations on every timeseries chart"
This reverts commit 5a00b9c096.
2026-05-02 20:20:18 +00:00
Viktor Barzin
5a00b9c096 monitoring(wealth): milestone annotations on every timeseries chart
Inspired by the user's "Journey to £1M" reference — adds vertical
dashed lines on every timeseries panel at the date net worth first
crossed each round threshold (£100k, £250k, £500k, £750k, £1M).

Implementation: a dashboard-level annotation source ("Milestones",
purple) backed by a PG query that finds the MIN(valuation_date) where
SUM(total_value) >= each threshold. The query returns (time, text)
pairs, e.g. "2026-04-18 → £1M 🎉". Annotations attach to all
timeseries panels automatically; auto-extends as future thresholds
are crossed.

Verified against current data:
  £100k → 2021-11-01    £250k → 2023-07-18    £500k → 2024-09-19
  £750k → 2025-08-26    £1M    → 2026-04-18 🎉

Future work (per user request): add a "Journey" stat-card row at the
top mirroring the reference (date achieved + months from previous).
2026-05-02 08:42:21 +00:00
Viktor Barzin
664a85ef1e Revert "monitoring(wealth): show daily points + lighter fill on timeseries"
This reverts commit 5472720c75.
2026-05-01 16:24:18 +00:00
Viktor Barzin
5472720c75 monitoring(wealth): show daily points + lighter fill on timeseries
Make daily movements visible on the line charts. The y-axis still spans
~£700k–£1M so an £8k daily move is ~1% of vertical range and easy to
miss when only the line is drawn.

Changes per panel:
  * 5 (Net worth):                  showPoints never→always, pointSize 4→5, fillOpacity 20→10
  * 6 (Net contrib vs market):      showPoints never→always, pointSize 4→5
  * 7 (Growth over time):           showPoints never→always, pointSize 4→5, fillOpacity 50→25
  * 8 (Per-account stacked):        showPoints never→always (kept stacking fill at 70)
  * 9 (Cash vs invested stacked):   showPoints never→always (kept stacking fill at 70)

Each daily value now renders as a visible dot, so even if the line
appears flat at this scale, the per-day points trace the wiggle. Lighter
fill on the unstacked panels lets the line + points dominate visually.

Caveat: the fundamental "£8k on a £1M base" visibility issue is best
solved with a dedicated "Daily change" delta panel — happy to add one
on next pass if this isn't enough.
2026-05-01 16:23:25 +00:00
Viktor Barzin
2722260ce9 monitoring(wealth): unbreak timeseries SQL — over-escaped time alias
Fix: panels 5–9 had `AS \"time\"` (literal backslash-quote sequence
embedded in the SQL string). PostgreSQL parsed that as a syntax error
at the leading backslash:

  ERROR:  syntax error at or near "\"
  LINE 1: ...complete_dates)) SELECT valuation_date::timestamp AS \"time\"

Root cause: the patch script for the skew-resilient queries (commit
628f5a0d) used a Python f-string with `\\\"time\\\"`, which produces
a literal backslash-quote in the Python string. When that string
was JSON-encoded the backslash was preserved verbatim instead of
collapsed to plain `"time"`.

Replaces all five occurrences with the correct `AS "time"` form.
Verified the corrected query against PG returns 7 daily net-worth
rows for 04-25..05-01 as expected.
2026-05-01 16:19:07 +00:00
Viktor Barzin
d67416d4ca monitoring(wealth): tighten default time range, bump decimals for granularity
Two adjustments to make daily movements visible:

1. Default time range: now-5y → now-180d. The timeseries charts (Net
   worth, Net contribution vs market value, Growth, Per-account
   stacked, Cash vs invested) auto-fit their y-axis to the data range
   in view. Over 5 years, daily £1k–£10k moves are ~1% of axis range
   and visually invisible against the cumulative trend. Over 6
   months, the same daily moves dominate. Yearly bar charts (12, 13)
   are unaffected — they aggregate by calendar year and don't filter
   on $__timeFilter.

2. Decimals → 2 on every currency panel (1, 2, 3, 5–9, 13, 15, 16)
   and every percent panel (4, 14). Stat panels now show pennies on
   currency and 0.01% on rates; chart y-axis ticks are likewise more
   precise. Honest caveat: pennies on a £1M number don't make the
   absolute readout easier — to see "today changed by £8,358" cleanly
   we'd want a dedicated delta panel; pending user direction.

Widen the time picker manually to recover the 5-year view; default
just zooms into the last 6 months.
2026-05-01 16:15:39 +00:00
Viktor Barzin
628f5a0d26 monitoring(wealth): skew-resilient queries, no more partial-day dips
Bug witnessed 2026-05-01: dashboard "Net worth (current)" showed £88k
instead of £1.03M because at 02:00 UTC an external trigger refreshed
ONE account (Trading212 ISA), creating its 05-01 daily_account_valuation
row. The 5 other accounts still had their last row at 04-30. The panel
SQL `WHERE valuation_date = (SELECT MAX(valuation_date))` then summed
only the single account that had a 05-01 row.

Two new SQL patterns adopted across all 15 affected panels:

  1. Stat / barchart "current snapshot" panels (1, 2, 3, 4, 11, 14, 15,
     16): latest-per-account stitching —
       WITH latest AS (SELECT DISTINCT ON (d.account_id) ...
                       FROM daily_account_valuation d
                       JOIN accounts a ON a.id = d.account_id
                       ORDER BY d.account_id, d.valuation_date DESC)
     gives a coherent "now" snapshot regardless of refresh skew, and
     the inner join filters out orphan/deleted accounts (one such was
     adding a stale £33k from 04-17). 12-month panels add a parallel
     `ago` CTE picking each account's row closest to (d_now - 12mo).

  2. Time-series / yearly panels (5, 6, 7, 8, 9, 12, 13): complete-days-
     only filter —
       WITH active_accounts AS (SELECT COUNT(*) FROM accounts),
            complete_dates AS (SELECT valuation_date
                               FROM daily_account_valuation d
                               JOIN accounts a ON a.id=d.account_id
                               GROUP BY valuation_date
                               HAVING COUNT(*) >= active.n)
     so a partial today never renders as a chart dip. The day rejoins
     the chart automatically once the daily 16:00 UTC sync writes rows
     for every account.

Verified end-to-end against live PG: new queries produce £1,033,734
(matches the 6 active accounts' true latest sum) where the old query
gave £88k.
2026-05-01 16:08:18 +00:00
Viktor Barzin
31b9e5d4a9 monitoring(wealth): add 12mo contrib + 12mo gain to top row
Top row goes from 5 → 7 stat panels (widths 4+4+4+3+3+3+3=24):

- Net worth, Net contribution, Growth shrink from w=5 to w=4.
- ROI % shrinks from w=5 to w=3 (now sits at x=12).
- 12mo return slides from x=20/w=4 to x=15/w=3.
- New: 12mo contrib (id=15, currency, blue) at x=18 — net contributions
  added in the trailing 12 months.
- New: 12mo gain (id=16, currency, red/green) at x=21 — pure market gain
  in £ over the trailing 12 months (12mo Δnet-worth − 12mo contribs).

Live values verified against PG: contrib_12mo=£245k, gain_12mo=£172k,
sum = £417k = nw_now − nw_ago, return = 23.51%.
2026-04-27 06:32:53 +00:00
Viktor Barzin
215717c90f monitoring(dashboards): tables at the bottom convention
wealth: move Activity log table from y=45 to y=77; the three barcharts
(Yearly return, Annual change, Per-account ROI) shift up by 14 to fill
the gap.

uk-payslip: move Sankey "where the money went" from y=80 to y=48 (right
above the table block); the three tables (Data integrity, All payslips,
YTD reconciliation) shift down by 14 so all four tables (4, 5, 6, 9) sit
contiguously at the bottom.

fire-planner and job-hunter still have intentional side-by-side
table/chart pairings; left untouched pending user direction on whether
to break them.
2026-04-26 18:30:52 +00:00
Viktor Barzin
bb28485ce0 monitoring(wealth): move 12mo return to top bar, shrink to w=4
Trailing 12-month investment return % was a full-width stat at y=59.
Now sits inline with Net worth / Contribution / Growth / ROI as the
fifth headline number — top-row stats reflowed from w=6 (×4) to w=5
(×4) + w=4 (×1). Title shortened to "12mo return" so it fits.
Panels below the old row shifted up by 4 rows to close the gap.
2026-04-26 18:19:24 +00:00
Viktor Barzin
a24cd7ceb7 monitoring(uk-payslip): yearly receipt aligns with P60 (RSU gross)
Switch the RSU stack from "after band-aware tax" to gross. Receipt
total is now pre-sacrifice gross compensation; bar − pension stack
≈ ytd_gross reported on the final March payslip / P60.

Verified alignment for 2025/26: bar−pension = £266,752 vs P60
ytd_gross = £268,127 — gap of £1,375 ≈ "other taxable" (benefits,
overtime). Remaining year-level gaps are upstream parser/ingest
issues, not dashboard logic:
  - 2024/25 +£27k: March 2025 payslip parsed bonus=£26,969 but never
    propagated it into gross_pay/income_tax. Receipt is more
    accurate than ytd_gross here.
  - 2023/24 −£36k: Feb 2024 payslip row appears to be missing from
    the table; ytd_gross has it, sum(gross_pay) doesn't.
  - 2022/23 −£10k: variant A→B transition residual.

SQL simplified — band-aware CTE chain dropped (no longer needed for
this panel since RSU is shown gross).
2026-04-26 10:24:06 +00:00
Viktor Barzin
222013806d monitoring(uk-payslip): split salary into cash + pension on yearly receipt
The salary field on the payslip is pre-pension-sacrifice, so the
"Salary (gross)" stack already silently included the salary-sacrifice
pension contribution. Split it out so pension is explicitly visible:
- Salary (cash, post-sacrifice) = salary - pension_sacrifice
- Pension (salary sacrifice, untaxed) = pension_sacrifice
- Bonus
- RSU vest (after band-aware tax)

Bar total unchanged (just relabels what was already there). Pension
is now visibly counted as income — consistent with "untaxed but real"
framing.

Caveat documented in panel description: receipt total ≠ P60 gross
because P60 reports pre-RSU-tax gross. Receipt shows RSU net of tax
per earlier intent. To exactly match P60, swap rsu_after_tax →
rsu_vest gross.
2026-04-26 09:18:32 +00:00
Viktor Barzin
21ac619fac monitoring(uk-payslip): promote yearly receipt + YTD gross YoY to row 4
Move both barchart/timeseries panels into row 4 (y=29, side-by-side
w=12 each, h=10) so the per-tax-year overviews appear right after
the income-tax-and-pension YTD row. Shift panels 13, 4, 5, 6, 8, 9
down by 10 to accommodate.

Final ordering: rows 1–3 = monthly + YTD timeseries (panels 1/7/2/3/11/12),
row 4 = yearly receipt + YTD gross YoY (16/17), then the wider
deduction/integrity/table panels below.
2026-04-25 23:58:15 +00:00
Viktor Barzin
53f555dc61 monitoring(uk-payslip): drop 3 panels referencing undeployed data
Removed:
- Panel 10 "HMRC Tax Year Reconciliation — Individual Tax API"
  → references hmrc_sync.tax_year_snapshot schema. The hmrc-sync
    service / DB has not been deployed, so the panel always errored
    with "relation does not exist".
- Panel 14 "Meta payroll: bank deposit vs payslip net pay"
  → references payslip_ingest.external_meta_deposits, which is
    created by alembic migration 0007. The deployed payslip-ingest
    image is at 0005, so the table doesn't exist.
- Panel 15 "RSU vest reconciliation — payslip vs Schwab"
  → references payslip_ingest.rsu_vest_events, created by migration
    0008. Same image-staleness story.

Verified all 14 remaining panels return without error via Grafana
/api/ds/query. SQL for the removed panels is preserved in git history;
re-add when the data sources are actually deployed.
2026-04-25 23:56:03 +00:00
Viktor Barzin
b2a25775aa monitoring(uk-payslip): simplify yearly receipt to earned-and-kept view
Replace the 7-stack "where total comp went" decomposition with a 3-stack
"what I actually earned" view: salary (gross), bonus (gross), and RSU
vest after band-aware tax (PAYE+NI withheld via sell-to-cover). Skips
income tax / NI / student loan / pension / RSU offset.

Bar height = real income kept across all components. RSU is net of tax
because it's withheld at source and never hits the bank account; salary
and bonus are gross because they're paid in full and taxes are deducted
elsewhere. This is the income-side view where tax is implicit, not the
deduction waterfall.

Per-year RSU after tax: 2020/21 £18k · 2021/22 £39k · 2022/23 £50k ·
2023/24 £26k · 2024/25 £71k · 2025/26 £73k.
2026-04-25 23:42:20 +00:00
Viktor Barzin
a17304f735 monitoring(uk-payslip): fix empty YTD gross YoY chart
Two bugs:
1. Synthetic dates projected onto 1970/71 fell outside the dashboard's
   default time range (now-10y → now), so Grafana filtered out every
   point. Switched to a sliding 12-month window
   (CURRENT_DATE - INTERVAL '12 months') as the projection base, plus
   a per-panel timeFrom: "13M" override so the panel always shows the
   last 13 months regardless of the dashboard's time picker.
2. ORDER BY tax_year, pay_date violated Grafana's long→wide conversion
   requirement (data must be ascending by time). Wrapped in a CTE and
   re-ordered by the synthetic time column. Pivoted result is now a
   single wide frame with 7 series (2019/20…2025/26).
2026-04-25 23:36:16 +00:00
Viktor Barzin
ac18c49a7b monitoring(wealth): fix x-axis label formatting on yearly bars
The default fieldConfig unit (percent on Yearly investment return %,
currencyGBP on Annual change decomposition) was being applied to the
"year" string column too — so x-axis labels rendered as "2024%" and
"£2,024" respectively. Add field overrides on the "year" column to
force unit=string. The earlier "tax_year" panels weren't affected
because "2024/25" doesn't parse as a number; "2024" did.
2026-04-25 23:31:03 +00:00
Viktor Barzin
77bed10a51 monitoring: investment-only returns + YoY YTD gross line chart
Wealth dashboard:
- "Yearly growth %" → "Yearly investment return %": switched to
  modified-Dietz formula `market_gain / (nw_start + 0.5 × contributions)`
  so contributions don't inflate the return. New money in is excluded —
  this is portfolio performance, not net-worth change.
- "Trailing 12-month growth %" → "Trailing 12-month investment return %":
  same formula, applied to the trailing 12mo window.

Pre-fix vs post-fix:
  2020: 155.0% → 5.12%   (large contributions on small base)
  2021: 344.7% → 26.45%
  2022: 26.9%  → -25.65% (the actual 2022 bear market)
  2023: 123.2% → 41.60%
  2024: 87.4%  → 25.70%
  2025: 46.8%  → 8.43%
  2026: 16.7%  → 3.28%   (YTD)

UK Payslip dashboard:
- Replaced the per-tax-year stacked bar with a year-over-year line chart:
  one line per tax year, X = month-of-tax-year (April→March, projected
  onto a 1970/71 fiscal calendar so years overlay), Y = cumulative YTD
  gross. Five+ lines visible at a glance for trend comparison.
2026-04-25 23:25:42 +00:00
Viktor Barzin
55d1da41f6 monitoring: more growth detail in Wealth + gross composition in UK Payslip
Wealth (4 new panels at the bottom):
- Trailing 12-month growth % (stat) — % change in net worth over last 12mo.
- Yearly growth % (bar per calendar year) — first→last valuation each year.
- Annual change decomposition (stacked bar) — splits each year's NW change
  into "net contributions" (new money in) and "market gain" (everything
  else: appreciation, dividends, FX). Answers "did I grow because I saved
  or because the market did the work?".
- Per-account ROI % (horizontal bar) — (value − contribution) / contribution
  × 100, latest snapshot. Excludes accounts with zero/negative net
  contribution (Schwab — distorts ratio after RSU sells).

UK Payslip (1 new panel below the yearly receipt):
- Gross composition by tax year (stacked bar) — salary / bonus / RSU vest /
  other components per tax year. Bar height = gross pay. Trends in salary
  growth, bonus levels, and RSU vest sizing at a glance.

All queries spot-checked via Grafana /api/ds/query.
2026-04-25 23:21:42 +00:00
Viktor Barzin
d48e222054 monitoring: lock Finance (Personal) folder to admin + fix cash classification
Folder ACL:
- Move uk-payslip + wealth dashboards to a new "Finance (Personal)"
  folder; job-hunter + fire-planner stay in "Finance" (open).
- New null_resource calls Grafana's folder permissions API after the
  dashboard sidecar materialises the folder, setting an admin-only
  ACL ({Admin: 4}). Default Viewer/Editor inheritance is overridden,
  so anonymous-Viewer (auth.anonymous=true) is denied. Server-admin
  always retains access.
- Verified: anonymous → 403 on uk-payslip + wealth, 200 on
  control dashboards (node-exporter); admin → 200 on all.

Wealth cash fix:
- Wealthfolio dumps WORKPLACE_PENSION wrappers entirely into
  cash_balance because it doesn't track underlying fund holdings.
  Reclassify pension cash as invested in the "Cash vs invested"
  panel so the cash series reflects actual uninvested broker cash
  (~£16k T212 ISA + Schwab) instead of phantom £154k.

  Pre-fix:  cash=£153,789 / invested=£870,282 / total=£1,024,071
  Post-fix: cash=£16,064  / invested=£1,008,008 / total=£1,024,071
2026-04-25 23:11:26 +00:00
Viktor Barzin
f0ce7b0363 fire-planner: add stack, Vault DB role, dashboard, DB
New stacks/fire-planner/ mirrors payslip-ingest layout:
- ExternalSecret pulling RECOMPUTE_BEARER_TOKEN from Vault secret/fire-planner
- DB ExternalSecret templating DB_CONNECTION_STRING via static role pg-fire-planner
- FastAPI Deployment (serve), CronJob (recompute-all monthly on 2nd at 09:00 UTC,
  scheduled after wealthfolio-sync's 1st at 08:00), ClusterIP Service
- Grafana datasource ConfigMap "FirePlanner" — `database` inside jsonData
  (cc56ba29 fix; otherwise Grafana 11.2+ hits "you do not have default database")

Plus:
- vault/main.tf: pg-fire-planner static role (7d rotation), allowed_roles
- dbaas/modules/dbaas/main.tf: null_resource creates fire_planner DB+role
- monitoring/dashboards/fire-planner.json: 9-panel Finance-folder dashboard
  (NW timeseries, MC fan chart, success heatmap, lifetime tax bars,
  years-to-ruin table, optimal leave-UK stat, ending wealth stat,
  UK success-by-strategy bars, sequence-risk correlation table)
- monitoring/modules/monitoring/grafana.tf: register "fire-planner.json" in Finance folder

Apply order:
  1. vault stack — creates the static role
  2. dbaas stack — creates the database & role
  3. external-secrets stack picks up vault-database refs (no change needed)
  4. fire-planner stack — first apply with -target=kubernetes_manifest.db_external_secret
     before full apply, per the plan-time-data-source pattern
  5. monitoring stack — picks up the new dashboard ConfigMap

[ci skip]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:27:19 +00:00
Viktor Barzin
bf4c7618d8 wealth: SQLite→PG ETL sidecar + new Grafana dashboard
Mirrors Wealthfolio's daily_account_valuation / accounts / activities
from SQLite into a new PG database (wealthfolio_sync) every hour, so
Grafana can chart net worth, contributions, and growth over time.

Components:
- dbaas: null_resource creates wealthfolio_sync DB + role on the CNPG
  cluster (dynamic primary lookup so it survives failover).
- vault: pg-wealthfolio-sync static role rotates the password every 7d.
- wealthfolio: ExternalSecret pulls the rotated password into the WF
  namespace; new pg-sync sidecar (alpine + sqlite + postgresql-client +
  busybox crond) does sqlite3 .backup → TSV dump → truncate-and-reload
  psql, hourly at :07. Plus a grafana-wealth-datasource ConfigMap in
  the monitoring namespace (uid: wealth-pg).
- monitoring: new Wealth dashboard (wealth.json, 10 panels) — current
  net worth / contribution / growth / ROI% stats, then time-series
  for net worth, contribution-vs-market, growth area, per-account
  stacked area, cash-vs-invested, and a 100-row activity log.

Initial sync: 6 accounts, 10,798 daily valuations, 518 activities.
Verified PG totals match SQLite latest snapshot exactly.
2026-04-25 17:07:33 +00:00
Viktor Barzin
4f5f1ff8c2 monitoring(uk-payslip): add yearly receipt stacked barchart panel
New panel 16 (barchart, h=11, y=179): one stacked bar per tax year showing
total comp split into net pay (bank deposit), cash income tax, RSU tax
(band-aware marginal: PAYE+NI), cash NI, student loan, pension salary-
sacrifice, and RSU offset (Variant A only).

X-axis = tax_year (categorical), y-axis = currencyGBP. Bar height ≈
gross_pay + pension_sacrifice (small over-attribution in Variant A years
where the band-aware model exceeds recorded payslip PAYE).
2026-04-25 16:26:57 +00:00
Viktor Barzin
b3c29eda12 monitoring(uk-payslip): model UK income-tax bands + PA-taper for RSU marginal
Replaces the flat 47% (45 PAYE + 2 NI) RSU marginal across panels 3, 7, 8, 11,
and 12 with an exact piecewise band-aware computation. Each row computes
ani_prior/ani_pre/ani_post over the tax-year YTD (chronological model — the
RSU is taxed at the band its YTD ANI position occupies at the vest date,
mirroring PAYE withholding behaviour).

Bands (2024/25+, applied to all years):
  IT:  0% / 20% / 40% / 60% (PA-taper) / 45%   at 12,570 / 50,270 / 100k / 125,140
  NI:  0% / 8% / 2%                            at 12,570 / 50,270

PA-taper modelled as 60% effective IT marginal in £100k–£125,140
(40% on the £1 + 40% on the £0.50 of lost PA = 60%).

Spot-checked per tax-year totals via psql; numbers diverge from the flat
47% baseline most for years where vests cross PA-taper or basic-rate bands
(2020/21 ~35%, 2024/25 ~41%, 2025/26 ~43%).
2026-04-25 16:14:49 +00:00