broker-sync/tests/providers/test_fidelity_planviewer.py
Viktor Barzin cb159e17d9 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.
2026-05-16 13:56:25 +00:00

148 lines
5.5 KiB
Python

from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
import pytest
from broker_sync.models import Account, AccountType
from broker_sync.providers.fidelity_planviewer import (
ACCOUNT_ID,
FidelityCreds,
FidelityPlanViewerProvider,
FidelityProviderConfigError,
fidelity_holdings_to_snapshot,
)
from broker_sync.providers.parsers.fidelity import (
parse_transactions_html,
parse_valuation_json,
)
_FIXTURES = Path(__file__).parent.parent / "fixtures" / "fidelity"
def test_accounts_exposes_single_workplace_pension_account() -> None:
prov = FidelityPlanViewerProvider(FidelityCreds(
storage_state_path="/tmp/x", plan_id="META",
))
assert prov.accounts() == [
Account(
id=ACCOUNT_ID,
name="Fidelity UK Pension",
account_type=AccountType.WORKPLACE_PENSION,
currency="GBP",
provider="fidelity-planviewer",
),
]
async def test_fetch_raises_without_storage_state() -> None:
prov = FidelityPlanViewerProvider(FidelityCreds(
storage_state_path="/tmp/does-not-exist-xyzzy.json", plan_id="META",
))
with pytest.raises(FidelityProviderConfigError, match="storage_state"):
async for _ in prov.fetch():
pytest.fail("should have raised before yielding")
# -- parser tests against real (captured) fixture --
def test_parse_transactions_real_fixture() -> None:
html = (_FIXTURES / "transactions-full.html").read_text()
txs = parse_transactions_html(html)
# Scheme has ~48 months + a couple of single premiums + 1 rebate;
# Bulk Switches must be filtered out (zero-amount rows).
assert 40 <= len(txs) <= 100
# All dates are within the scheme's lifetime (2022-03 to today-ish).
assert all(tx.date >= datetime(2022, 1, 1, tzinfo=UTC) for tx in txs)
# Sum should match the header total on the page (£102,004.15 at
# fixture time). Allow a £5 tolerance in case the page summary row
# changes in future captures — the unit test primarily guards parsing
# correctness, not drift in the fixture.
total = sum((tx.amount for tx in txs), Decimal(0))
assert abs(total - Decimal("102004.15")) < Decimal("5")
def test_parse_transactions_skips_bulk_switch() -> None:
html = (_FIXTURES / "transactions-full.html").read_text()
txs = parse_transactions_html(html)
assert not any("bulk switch" in tx.tx_type.lower() for tx in txs)
def test_parse_transactions_external_id_deterministic() -> None:
html = (_FIXTURES / "transactions-full.html").read_text()
a = parse_transactions_html(html)
b = parse_transactions_html(html)
assert [tx.external_id for tx in a] == [tx.external_id for tx in b]
assert all(tx.external_id.startswith("fidelity:tx:") for tx in a)
def test_parse_valuation_fixture() -> None:
payload = json.loads((_FIXTURES / "valuation.json").read_text())
holdings = parse_valuation_json(payload)
assert len(holdings) >= 1
h = holdings[0]
assert h.fund_code == "KDOA"
assert "Passive Global Equity" in h.fund_name
assert h.currency == "GBP"
assert h.units > 0
assert h.unit_price > 0
# Value ≈ units * price
assert abs(h.total_value - h.units * h.unit_price) < Decimal("1")
# Contribution-type breakdown must parse
assert set(h.units_by_source.keys()) >= {"SASC", "ERXS"}
def test_holdings_to_snapshot_real_fixture() -> None:
html = (_FIXTURES / "transactions-full.html").read_text()
valuation = json.loads((_FIXTURES / "valuation.json").read_text())
holdings = parse_valuation_json(valuation)
total_contrib = sum((tx.amount for tx in parse_transactions_html(html)),
Decimal(0))
snapshot = fidelity_holdings_to_snapshot(
holdings=holdings,
total_real_contribution=total_contrib,
as_of=date(2026, 4, 18),
)
assert snapshot is not None
assert snapshot.date == date(2026, 4, 18)
assert snapshot.currency == "GBP"
# Cost basis sums to the cash contributions (allocated by fund value share)
sum_cost = sum((p.total_cost_basis for p in snapshot.positions), Decimal(0))
assert abs(sum_cost - total_contrib) < Decimal("1")
# Meta scheme had KDOA + LAFC + one other at fixture time; the
# dominant fund must be KDOA.
symbols = [p.symbol for p in snapshot.positions]
assert "KDOA" in symbols
kdoa = next(p for p in snapshot.positions if p.symbol == "KDOA")
assert kdoa.quantity > 0
# Proportional cost-basis allocation: KDOA holds nearly the whole pot
# so it should get the lion's share of cost
kdoa_share = kdoa.total_cost_basis / sum_cost
assert kdoa_share > Decimal("0.9")
# cashBalances zero — pension contributions flow straight into funds
assert snapshot.cash_balances == {"GBP": Decimal(0)}
def test_holdings_to_snapshot_none_when_no_holdings() -> None:
assert fidelity_holdings_to_snapshot(
holdings=[], total_real_contribution=Decimal("100"),
as_of=date(2026, 4, 18),
) is None
def test_provider_caches_holdings_for_cli_snapshot_push() -> None:
"""The CLI reads `last_holdings` after fetch() drains to push the
manual snapshot. This guards the contract that fetch() populates the
attribute even when no Activity is yielded (e.g., backfill window
cut-off)."""
prov = FidelityPlanViewerProvider(FidelityCreds(
storage_state_path="/tmp/x", plan_id="META",
))
# Pre-fetch state: empty
assert prov.last_holdings == []
assert prov.last_total_contribution == Decimal(0)