Compare commits

...

5 commits

Author SHA1 Message Date
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
0044c3a8ea fire-planner: add examples ingest Job (toggled) + weekly CronJob
Adds the K8s plumbing for the Reddit FIRE-examples ingest path:

- ExternalSecret fire-planner-examples-reddit (Reddit OAuth from
  Vault secret/viktor.trading_bot_reddit_{client_id,client_secret}).
- ExternalSecret fire-planner-examples-claude (claude-agent-service
  bearer from Vault secret/claude-agent-service.api_bearer_token).
- kubernetes_job_v1.examples_bulk_ingest — one-shot bulk Job toggled
  via var.run_examples_bulk_ingest (default false). Timestamp-named so
  each (true) transition creates a fresh Job; lifecycle ignores the
  name so re-plans don't propose phantom renames.
- kubernetes_cron_job_v1.examples_weekly_delta — Sunday 04:00 UTC
  --top=week --limit=200 incremental run.

Both runners share the env_from plumbing of the existing recompute
CronJob (fire-planner-secrets, fire-planner-db-creds,
wealthfolio-sync-db-creds) plus examples-specific vars
(REDDIT_USER_AGENT, LLAMA_CPP_BASE_URL, CLAUDE_AGENT_SERVICE_URL,
plus the three secret-backed env vars).

Plan-only this commit — actual apply lands in Task 17 after the
ingest image build.
2026-05-28 22:51:14 +00:00
Viktor Barzin
4dff834c8a reduce ingress-dns-sync frequency to hourly [ci skip] 2026-05-28 22:30:08 +00:00
Viktor Barzin
5ac8d625b9 add ingress-dns-sync CronJob to auto-create Technitium CNAME records
Discovers all *.viktorbarzin.me ingress hosts every 15 minutes and
creates matching CNAME records in Technitium if missing. Prevents
the desync where Cloudflare has the DNS record (via ingress_factory)
but internal DNS returns NXDOMAIN because Technitium was never updated.

Includes ServiceAccount + ClusterRole for ingress list permissions.
2026-05-28 22:22:42 +00:00
Viktor Barzin
58cced5dab monitoring: render market-vs-salary periodic panels as lines, not bars 2026-05-28 22:18:59 +00:00
4 changed files with 499 additions and 31 deletions

View file

@ -546,7 +546,7 @@ module "ingress_api" {
ingress_path = ["/api/"]
tls_secret_name = var.tls_secret_name
# auth = "none": XHR-based API endpoints; forward-auth 302+cookie-dance breaks CORS preflight and browser fetch().
auth = "none"
auth = "none"
}
# Plan-time read of the ESO-created K8s Secret for Grafana datasource
@ -605,3 +605,318 @@ resource "kubernetes_config_map" "grafana_fire_planner_datasource" {
# CI retrigger 2026-05-16T13:42:57+00:00 bulk enrollment apply (pipeline #689 killed)
# CI retrigger v2 2026-05-16T13:46:35+00:00
# ----------------------------------------------------------------------
# Reddit FIRE examples ingest Job (bulk, toggled) + weekly CronJob
# Backs the fire_planner.examples module. See:
# ~/code/fire-planner/docs/plans/2026-05-28-reddit-examples-{design,plan}.md
# ----------------------------------------------------------------------
variable "llama_cpp_base_url" {
type = string
description = "llama-cpp /v1/chat/completions endpoint for primary LLM extraction"
default = "http://llama-cpp.llama-cpp.svc.cluster.local:8000/v1/chat/completions"
}
variable "claude_agent_service_url" {
type = string
description = "claude-agent-service /v1/chat/completions endpoint for Tier 2 fallback"
default = "http://claude-agent-service.claude-agent.svc.cluster.local:8080/v1/chat/completions"
}
variable "run_examples_bulk_ingest" {
type = bool
description = "Flip to true once to bulk-populate fire_example. Reset to false after."
default = false
}
# Reddit OAuth creds pulled from Vault secret/viktor.
resource "kubernetes_manifest" "external_secret_examples_reddit" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "fire-planner-examples-reddit"
namespace = local.namespace
}
spec = {
refreshInterval = "1h"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "fire-planner-examples-reddit"
}
data = [
{
secretKey = "REDDIT_CLIENT_ID"
remoteRef = {
key = "viktor"
property = "trading_bot_reddit_client_id"
}
},
{
secretKey = "REDDIT_CLIENT_SECRET"
remoteRef = {
key = "viktor"
property = "trading_bot_reddit_client_secret"
}
},
]
}
}
depends_on = [kubernetes_namespace.fire_planner]
}
# claude-agent-service bearer pulled separately so its rotation cadence
# is decoupled from the Reddit creds.
resource "kubernetes_manifest" "external_secret_examples_claude" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "fire-planner-examples-claude"
namespace = local.namespace
}
spec = {
refreshInterval = "1h"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "fire-planner-examples-claude"
}
data = [
{
secretKey = "CLAUDE_AGENT_BEARER"
remoteRef = {
key = "claude-agent-service"
property = "api_bearer_token"
}
},
]
}
}
depends_on = [kubernetes_namespace.fire_planner]
}
# Bulk one-shot Job toggled via var.run_examples_bulk_ingest. Flip to
# true once, apply, wait for completion, flip back. The timestamp() in
# the name ensures Terraform creates a fresh Job on each (true)
# transition rather than refusing to recreate an existing one.
resource "kubernetes_job_v1" "examples_bulk_ingest" {
count = var.run_examples_bulk_ingest ? 1 : 0
metadata {
name = "fire-planner-examples-bulk-${formatdate("YYYYMMDDhhmm", timestamp())}"
namespace = kubernetes_namespace.fire_planner.metadata[0].name
}
spec {
backoff_limit = 0
template {
metadata {
labels = local.labels
}
spec {
restart_policy = "OnFailure"
image_pull_secrets {
name = "registry-credentials"
}
container {
name = "ingest"
image = local.image
image_pull_policy = "IfNotPresent"
command = ["python", "-m", "fire_planner", "examples", "ingest",
"--top=all,year", "--limit=1000"]
# DB plumbing mirror the fire_planner_recompute CronJob.
env_from {
secret_ref {
name = "fire-planner-secrets"
}
}
env_from {
secret_ref {
name = "fire-planner-db-creds"
}
}
env_from {
secret_ref {
name = "wealthfolio-sync-db-creds"
}
}
# Examples-specific vars.
env {
name = "REDDIT_CLIENT_ID"
value_from {
secret_key_ref {
name = "fire-planner-examples-reddit"
key = "REDDIT_CLIENT_ID"
}
}
}
env {
name = "REDDIT_CLIENT_SECRET"
value_from {
secret_key_ref {
name = "fire-planner-examples-reddit"
key = "REDDIT_CLIENT_SECRET"
}
}
}
env {
name = "CLAUDE_AGENT_BEARER"
value_from {
secret_key_ref {
name = "fire-planner-examples-claude"
key = "CLAUDE_AGENT_BEARER"
}
}
}
env {
name = "REDDIT_USER_AGENT"
value = "fire-planner/0.1"
}
env {
name = "LLAMA_CPP_BASE_URL"
value = var.llama_cpp_base_url
}
env {
name = "CLAUDE_AGENT_SERVICE_URL"
value = var.claude_agent_service_url
}
}
}
}
}
lifecycle {
# The name embeds a timestamp so a re-plan after time has passed
# would otherwise propose a no-op rename. Ignore.
# KYVERNO_LIFECYCLE_V1
ignore_changes = [
metadata[0].name,
spec[0].template[0].spec[0].dns_config,
]
}
depends_on = [
kubernetes_manifest.external_secret,
kubernetes_manifest.db_external_secret,
kubernetes_manifest.wealthfolio_sync_db_external_secret,
kubernetes_manifest.external_secret_examples_reddit,
kubernetes_manifest.external_secret_examples_claude,
]
}
# Weekly delta top-of-week milestone posts. Sunday 04:00 UTC.
resource "kubernetes_cron_job_v1" "examples_weekly_delta" {
metadata {
name = "fire-planner-examples-weekly"
namespace = kubernetes_namespace.fire_planner.metadata[0].name
}
spec {
schedule = "0 4 * * 0"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
job_template {
metadata {
labels = local.labels
}
spec {
backoff_limit = 0
ttl_seconds_after_finished = 86400
template {
metadata {
labels = local.labels
}
spec {
restart_policy = "OnFailure"
image_pull_secrets {
name = "registry-credentials"
}
container {
name = "ingest"
image = local.image
image_pull_policy = "IfNotPresent"
command = ["python", "-m", "fire_planner", "examples", "ingest",
"--top=week", "--limit=200"]
# DB plumbing mirror the fire_planner_recompute CronJob.
env_from {
secret_ref {
name = "fire-planner-secrets"
}
}
env_from {
secret_ref {
name = "fire-planner-db-creds"
}
}
env_from {
secret_ref {
name = "wealthfolio-sync-db-creds"
}
}
# Examples-specific vars keep in sync with the bulk Job.
env {
name = "REDDIT_CLIENT_ID"
value_from {
secret_key_ref {
name = "fire-planner-examples-reddit"
key = "REDDIT_CLIENT_ID"
}
}
}
env {
name = "REDDIT_CLIENT_SECRET"
value_from {
secret_key_ref {
name = "fire-planner-examples-reddit"
key = "REDDIT_CLIENT_SECRET"
}
}
}
env {
name = "CLAUDE_AGENT_BEARER"
value_from {
secret_key_ref {
name = "fire-planner-examples-claude"
key = "CLAUDE_AGENT_BEARER"
}
}
}
env {
name = "REDDIT_USER_AGENT"
value = "fire-planner/0.1"
}
env {
name = "LLAMA_CPP_BASE_URL"
value = var.llama_cpp_base_url
}
env {
name = "CLAUDE_AGENT_SERVICE_URL"
value = var.claude_agent_service_url
}
}
}
}
}
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
}
depends_on = [
kubernetes_manifest.external_secret,
kubernetes_manifest.db_external_secret,
kubernetes_manifest.wealthfolio_sync_db_external_secret,
kubernetes_manifest.external_secret_examples_reddit,
kubernetes_manifest.external_secret_examples_claude,
]
}

View file

@ -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"
}
]
},
@ -2626,14 +2626,13 @@
"unit": "currencyGBP",
"decimals": 0,
"custom": {
"drawStyle": "bars",
"barAlignment": 0,
"lineWidth": 1,
"fillOpacity": 70,
"gradientMode": "none",
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"gradientMode": "opacity",
"pointSize": 5,
"showPoints": "never",
"spanNulls": false,
"showPoints": "auto",
"spanNulls": true,
"axisPlacement": "auto",
"stacking": {
"group": "A",
@ -2743,14 +2742,13 @@
"unit": "currencyGBP",
"decimals": 0,
"custom": {
"drawStyle": "bars",
"barAlignment": 0,
"lineWidth": 1,
"fillOpacity": 70,
"gradientMode": "none",
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"gradientMode": "opacity",
"pointSize": 5,
"showPoints": "never",
"spanNulls": false,
"showPoints": "auto",
"spanNulls": true,
"axisPlacement": "auto",
"stacking": {
"group": "A",

View file

@ -799,3 +799,120 @@ sys.exit(0 if correct else 1)
}
}
# ServiceAccount + RBAC for the ingress-dns-sync CronJob to list ingresses cluster-wide.
resource "kubernetes_service_account" "ingress_dns_sync" {
metadata {
name = "ingress-dns-sync"
namespace = kubernetes_namespace.technitium.metadata[0].name
}
}
resource "kubernetes_cluster_role" "ingress_dns_sync" {
metadata {
name = "ingress-dns-sync"
}
rule {
api_groups = ["networking.k8s.io"]
resources = ["ingresses"]
verbs = ["list"]
}
}
resource "kubernetes_cluster_role_binding" "ingress_dns_sync" {
metadata {
name = "ingress-dns-sync"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = kubernetes_cluster_role.ingress_dns_sync.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.ingress_dns_sync.metadata[0].name
namespace = kubernetes_namespace.technitium.metadata[0].name
}
}
# CronJob to sync K8s Ingress hosts -> Technitium CNAME records.
# Discovers all *.viktorbarzin.me ingress hosts, ensures each has a CNAME
# pointing to viktorbarzin.me in Technitium's authoritative zone.
# Prevents the desync where Cloudflare has the record but internal DNS doesn't.
resource "kubernetes_cron_job_v1" "technitium_ingress_dns_sync" {
metadata {
name = "technitium-ingress-dns-sync"
namespace = kubernetes_namespace.technitium.metadata[0].name
}
spec {
schedule = "0 * * * *"
successful_jobs_history_limit = 1
failed_jobs_history_limit = 3
job_template {
metadata {}
spec {
template {
metadata {}
spec {
service_account_name = kubernetes_service_account.ingress_dns_sync.metadata[0].name
container {
name = "sync"
image = "bitnami/kubectl:latest"
resources {
requests = {
cpu = "10m"
memory = "32Mi"
}
limits = {
memory = "64Mi"
}
}
env {
name = "TECH_USER"
value = var.technitium_username
}
env {
name = "TECH_PASS"
value = var.technitium_password
}
command = ["/bin/sh", "-c", <<-EOT
set -e
ZONE="viktorbarzin.me"
TECH_API="http://technitium-web:5380"
TOKEN=$$(curl -sf "$$TECH_API/api/user/login?user=$$TECH_USER&pass=$$TECH_PASS" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [ -z "$$TOKEN" ]; then echo "Login failed"; exit 1; fi
EXISTING=$$(curl -sf "$$TECH_API/api/zones/records/get?token=$$TOKEN&zone=$$ZONE&domain=$$ZONE&listZone=true" | grep -o '"name":"[^"]*\.viktorbarzin\.me"' | sed 's/"name":"//;s/"//' | sort -u)
HOSTS=$$(kubectl get ingress -A -o jsonpath='{range .items[*]}{range .spec.rules[*]}{.host}{"\n"}{end}{end}' | grep "\.$$ZONE$$" | grep -v "^$$ZONE$$" | sort -u)
CREATED=0
for HOST in $$HOSTS; do
if echo "$$EXISTING" | grep -qx "$$HOST"; then
continue
fi
RESULT=$$(curl -sf "$$TECH_API/api/zones/records/add?token=$$TOKEN&zone=$$ZONE&domain=$$HOST&type=CNAME&cname=$$ZONE&ttl=86400" 2>&1) || true
if echo "$$RESULT" | grep -q '"status":"ok"'; then
echo "Created CNAME: $$HOST -> $$ZONE"
CREATED=$$((CREATED + 1))
elif echo "$$RESULT" | grep -q 'already exists'; then
echo "Already exists: $$HOST"
else
echo "Failed: $$HOST -- $$RESULT"
fi
done
echo "Sync complete. Created $$CREATED new records."
EOT
]
}
restart_policy = "OnFailure"
}
}
}
}
}
lifecycle {
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
}
}

View file

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