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>
This commit is contained in:
parent
0044c3a8ea
commit
4f71ce6bc5
2 changed files with 54 additions and 16 deletions
|
|
@ -1760,7 +1760,7 @@
|
|||
"rawQuery": true,
|
||||
"editorMode": "code",
|
||||
"format": "table",
|
||||
"rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), yearly AS (SELECT EXTRACT(YEAR FROM valuation_date)::int AS yr, valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), endpoints AS (SELECT yr, (array_agg(nw ORDER BY valuation_date ASC))[1] AS nw_start, (array_agg(nw ORDER BY valuation_date DESC))[1] AS nw_end, (array_agg(contrib ORDER BY valuation_date ASC))[1] AS contrib_start, (array_agg(contrib ORDER BY valuation_date DESC))[1] AS contrib_end FROM yearly GROUP BY yr) SELECT yr::text AS year, ROUND((contrib_end - contrib_start)::numeric, 0) AS contributions, ROUND((nw_end - nw_start - (contrib_end - contrib_start))::numeric, 0) AS market_gain FROM endpoints ORDER BY yr"
|
||||
"rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), daily AS (SELECT valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), year_end AS (SELECT DISTINCT ON (EXTRACT(YEAR FROM valuation_date)) EXTRACT(YEAR FROM valuation_date)::int AS yr, nw, contrib FROM daily ORDER BY EXTRACT(YEAR FROM valuation_date), valuation_date DESC), deltas AS (SELECT yr, nw, contrib, lag(nw) OVER (ORDER BY yr) AS prev_nw, lag(contrib) OVER (ORDER BY yr) AS prev_contrib FROM year_end) SELECT yr::text AS year, ROUND((contrib - prev_contrib)::numeric, 0) AS contributions, ROUND(((nw - prev_nw) - (contrib - prev_contrib))::numeric, 0) AS market_gain FROM deltas WHERE prev_nw IS NOT NULL ORDER BY yr"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1865,7 +1865,7 @@
|
|||
"rawQuery": true,
|
||||
"editorMode": "code",
|
||||
"format": "time_series",
|
||||
"rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), monthly AS (SELECT date_trunc('month', valuation_date)::date AS month, valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), endpoints AS (SELECT month, (array_agg(nw ORDER BY valuation_date ASC))[1] AS nw_start, (array_agg(nw ORDER BY valuation_date DESC))[1] AS nw_end, (array_agg(contrib ORDER BY valuation_date ASC))[1] AS contrib_start, (array_agg(contrib ORDER BY valuation_date DESC))[1] AS contrib_end FROM monthly GROUP BY month) SELECT month::timestamp AS time, ROUND((contrib_end - contrib_start)::numeric, 0) AS contributions, ROUND((nw_end - nw_start - (contrib_end - contrib_start))::numeric, 0) AS market_gain FROM endpoints ORDER BY month"
|
||||
"rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), daily AS (SELECT valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), month_end AS (SELECT DISTINCT ON (date_trunc('month', valuation_date)) date_trunc('month', valuation_date)::date AS month, nw, contrib FROM daily ORDER BY date_trunc('month', valuation_date), valuation_date DESC), deltas AS (SELECT month, nw, contrib, lag(nw) OVER (ORDER BY month) AS prev_nw, lag(contrib) OVER (ORDER BY month) AS prev_contrib FROM month_end) SELECT month::timestamp AS time, ROUND((contrib - prev_contrib)::numeric, 0) AS contributions, ROUND(((nw - prev_nw) - (contrib - prev_contrib))::numeric, 0) AS market_gain FROM deltas WHERE prev_nw IS NOT NULL ORDER BY month"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ resource "kubernetes_deployment" "wealthfolio" {
|
|||
metadata[0].annotations["keel.sh/policy"],
|
||||
metadata[0].annotations["keel.sh/trigger"],
|
||||
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
|
||||
metadata[0].annotations["keel.sh/match-tag"],
|
||||
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE — Keel manages tag updates
|
||||
spec[0].template[0].spec[0].container[1].image,
|
||||
spec[0].template[0].spec[0].container[2].image,
|
||||
metadata[0].annotations["kubernetes.io/change-cause"],
|
||||
metadata[0].annotations["deployment.kubernetes.io/revision"],
|
||||
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
|
||||
]
|
||||
}
|
||||
metadata {
|
||||
|
|
@ -422,22 +429,53 @@ resource "kubernetes_deployment" "wealthfolio" {
|
|||
FROM activities
|
||||
WHERE notes LIKE 'cash-flow-match:%'
|
||||
AND account_id = '72d34e09-c1a6-41aa-99ea-abe3305ecc4a' -- Schwab
|
||||
),
|
||||
base AS (
|
||||
SELECT
|
||||
d.id, d.account_id, d.valuation_date, d.account_currency,
|
||||
d.base_currency, d.fx_rate_to_base, d.cash_balance,
|
||||
d.investment_market_value, d.total_value, d.cost_basis,
|
||||
d.net_contribution AS nc_raw,
|
||||
COALESCE(SUM(s.synthetic_net), 0) AS synthetic_adjustment
|
||||
FROM daily_account_valuation d
|
||||
LEFT JOIN synthetic_flows s
|
||||
ON s.account_id = d.account_id
|
||||
AND s.effective_date <= d.valuation_date
|
||||
GROUP BY d.id, d.account_id, d.valuation_date, d.account_currency,
|
||||
d.base_currency, d.fx_rate_to_base, d.cash_balance,
|
||||
d.investment_market_value, d.total_value, d.cost_basis,
|
||||
d.net_contribution
|
||||
),
|
||||
-- LOCF gap-fill: a Fidelity pension valuation of 0 is always a
|
||||
-- PlanViewer scrape gap (the pot can't really be £0), never a real
|
||||
-- balance. Without this a missed scrape craters net worth to £0 for
|
||||
-- the gap and the "Monthly contributions" panel shows a phantom
|
||||
-- withdrawal then rebound (witnessed Feb 2026: -£97k / +£100k). Carry
|
||||
-- the last non-zero day forward across the gap. Scoped to Fidelity
|
||||
-- (account_id below); brokerage 0s are left untouched.
|
||||
filled AS (
|
||||
SELECT *,
|
||||
SUM(CASE WHEN total_value > 0 THEN 1 ELSE 0 END)
|
||||
OVER (PARTITION BY account_id ORDER BY valuation_date) AS tv_grp
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
d.id, d.account_id, d.valuation_date, d.account_currency,
|
||||
d.base_currency, d.fx_rate_to_base, d.cash_balance,
|
||||
d.investment_market_value, d.total_value, d.cost_basis,
|
||||
d.net_contribution AS net_contribution_raw,
|
||||
(d.net_contribution - COALESCE(SUM(s.synthetic_net), 0)) AS net_contribution,
|
||||
COALESCE(SUM(s.synthetic_net), 0) AS synthetic_adjustment
|
||||
FROM daily_account_valuation d
|
||||
LEFT JOIN synthetic_flows s
|
||||
ON s.account_id = d.account_id
|
||||
AND s.effective_date <= d.valuation_date
|
||||
GROUP BY d.id, d.account_id, d.valuation_date, d.account_currency,
|
||||
d.base_currency, d.fx_rate_to_base, d.cash_balance,
|
||||
d.investment_market_value, d.total_value, d.cost_basis,
|
||||
d.net_contribution;
|
||||
id, account_id, valuation_date, account_currency, base_currency,
|
||||
fx_rate_to_base,
|
||||
CASE WHEN account_id = 'a7d6208d-2bd6-4f85-bf54-b77984c78234' AND total_value = 0
|
||||
THEN MAX(cash_balance) OVER w ELSE cash_balance END AS cash_balance,
|
||||
CASE WHEN account_id = 'a7d6208d-2bd6-4f85-bf54-b77984c78234' AND total_value = 0
|
||||
THEN MAX(investment_market_value) OVER w ELSE investment_market_value END AS investment_market_value,
|
||||
CASE WHEN account_id = 'a7d6208d-2bd6-4f85-bf54-b77984c78234' AND total_value = 0
|
||||
THEN MAX(total_value) OVER w ELSE total_value END AS total_value,
|
||||
CASE WHEN account_id = 'a7d6208d-2bd6-4f85-bf54-b77984c78234' AND total_value = 0
|
||||
THEN MAX(cost_basis) OVER w ELSE cost_basis END AS cost_basis,
|
||||
nc_raw AS net_contribution_raw,
|
||||
(CASE WHEN account_id = 'a7d6208d-2bd6-4f85-bf54-b77984c78234' AND total_value = 0
|
||||
THEN MAX(nc_raw) OVER w ELSE nc_raw END) - synthetic_adjustment AS net_contribution,
|
||||
synthetic_adjustment
|
||||
FROM filled
|
||||
WINDOW w AS (PARTITION BY account_id, tv_grp);
|
||||
SQL
|
||||
|
||||
# Snapshot SQLite (online backup — non-blocking).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue