fidelity: push per-fund manual snapshot instead of gains-offset DEPOSIT
PlanViewer's DisplayValuation.action JSON already gives us current fund units + unit price; we were parsing it and throwing it away, emitting only a single 'unrealised-gains-offset' DEPOSIT to make Wealthfolio's totals match the dashboard. That hack double-counted the gain as a cash contribution, hiding £35k of pension growth from every contribution/growth/ROI panel. New flow: - FidelityPlanViewerProvider exposes last_holdings + last_total_contribution after fetch() drains. - fidelity-ingest CLI converts to a ManualSnapshotPayload (cost basis allocated proportionally by current fund value share) and posts to WF /api/v1/snapshots/import. WF auto-creates unknown fund symbols with kind=INVESTMENT, quoteMode=MANUAL, quoteCcy=GBP. - The gains-offset emission is removed entirely. Historical offset rows already in WF are corrected at the dashboard layer by the dav_corrected view shipped in infra@2841347e. WealthfolioSink gains push_manual_snapshots() + ManualSnapshotPayload / SnapshotPosition wire types. 11 sink tests (3 new) + 9 fidelity provider tests (2 changed, 1 new) all green; mypy + ruff clean.
This commit is contained in:
parent
5adc4a7ba4
commit
cb159e17d9
5 changed files with 339 additions and 60 deletions
|
|
@ -2,7 +2,9 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ _LOGIN_PATH = "/api/v1/auth/login"
|
|||
_ACCOUNTS_PATH = "/api/v1/accounts"
|
||||
_IMPORT_CHECK = "/api/v1/activities/import/check"
|
||||
_IMPORT_REAL = "/api/v1/activities/import"
|
||||
_SNAPSHOTS_IMPORT = "/api/v1/snapshots/import"
|
||||
|
||||
|
||||
class WealthfolioError(Exception):
|
||||
|
|
@ -262,3 +265,83 @@ class WealthfolioSink:
|
|||
f"First warning: {first_warn}")
|
||||
assert isinstance(got, list)
|
||||
return [r for r in got if isinstance(r, dict)]
|
||||
|
||||
# -- manual holdings snapshots --
|
||||
|
||||
async def push_manual_snapshots(
|
||||
self,
|
||||
account_id: str,
|
||||
snapshots: list[ManualSnapshotPayload],
|
||||
) -> dict[str, Any]:
|
||||
"""Push manual holdings snapshots to /api/v1/snapshots/import.
|
||||
|
||||
Each snapshot carries a date + per-fund positions + cash balances.
|
||||
Wealthfolio auto-creates any unknown asset symbol with
|
||||
``kind=INVESTMENT, quoteMode=MANUAL, quoteCcy=<currency>`` and uses
|
||||
the snapshot to derive holdings + valuation for that date — bypassing
|
||||
the activity-ledger derivation entirely for the targeted day.
|
||||
|
||||
Used by the Fidelity provider since PlanViewer exposes current
|
||||
fund units + price but no per-trade history. Re-imports for the
|
||||
same (account, date) overwrite in place.
|
||||
"""
|
||||
if not snapshots:
|
||||
return {"snapshotsImported": 0, "snapshotsFailed": 0, "errors": []}
|
||||
body = {
|
||||
"accountId": account_id,
|
||||
"snapshots": [_snapshot_to_payload(s) for s in snapshots],
|
||||
}
|
||||
resp = await self._request("POST", _SNAPSHOTS_IMPORT, json=body)
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
payload = {"raw": resp.text}
|
||||
raise WealthfolioError(
|
||||
f"Wealthfolio /snapshots/import rejected: {payload}")
|
||||
result = resp.json()
|
||||
assert isinstance(result, dict)
|
||||
failed = int(result.get("snapshotsFailed", 0))
|
||||
if failed > 0:
|
||||
raise WealthfolioError(
|
||||
f"Wealthfolio /snapshots/import: {failed} snapshot(s) failed; "
|
||||
f"errors={result.get('errors')}")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SnapshotPosition:
|
||||
"""A per-fund position row in a Wealthfolio manual snapshot."""
|
||||
symbol: str
|
||||
quantity: Decimal
|
||||
average_cost: Decimal
|
||||
total_cost_basis: Decimal
|
||||
currency: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualSnapshotPayload:
|
||||
"""Sink-facing snapshot row. Mirrors the JSON shape WF expects."""
|
||||
date: date
|
||||
currency: str
|
||||
positions: list[SnapshotPosition]
|
||||
cash_balances: dict[str, Decimal]
|
||||
|
||||
|
||||
def _snapshot_to_payload(s: ManualSnapshotPayload) -> dict[str, Any]:
|
||||
"""Serialise a ManualSnapshotPayload into WF's import wire format."""
|
||||
return {
|
||||
"date": s.date.isoformat(),
|
||||
"currency": s.currency,
|
||||
"positions": [
|
||||
{
|
||||
"symbol": p.symbol,
|
||||
"quantity": format(p.quantity, "f"),
|
||||
"averageCost": format(p.average_cost, "f"),
|
||||
"totalCostBasis": format(p.total_cost_basis, "f"),
|
||||
"currency": p.currency,
|
||||
}
|
||||
for p in s.positions
|
||||
],
|
||||
"cashBalances": {k: format(v, "f") for k, v in s.cash_balances.items()},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue