Per-fund snapshot import landed quantities but dropped cost basis + needed a separate quote-push path we never identified. Snapshotting also collided with WF's own TOTAL aggregation and ZEROED the Fidelity cash balance. Simpler plan: each monthly scrape emits a single DEPOSIT (or WITHDRAWAL on a market drop) sized to the delta between the live PlanViewer pot value and Wealthfolio's running total. dav_corrected PG view continues to subtract these offsets from net_contribution so the dashboard Growth/ROI math stays right. - New gains_offset_delta_activity() — current_gain - prior_offset. - New WealthfolioSink.cumulative_amount_with_notes_prefix() — sums the existing fidelity-planviewer:unrealised-gains-offset DEPOSITs in WF so we know what's already been emitted. - CLI runs sync_provider_to_wealthfolio first (cash flows), then computes + emits the delta via import_activities. - 4 new provider tests for the delta logic; full suite (144 + 1 skipped) green; mypy + ruff clean. The old fidelity_holdings_to_snapshot helper + push_manual_snapshots sink method stay for future use but are no longer called.
209 lines
7.9 KiB
Python
209 lines
7.9 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, ActivityType
|
|
from broker_sync.providers.fidelity_planviewer import (
|
|
ACCOUNT_ID,
|
|
FidelityCreds,
|
|
FidelityPlanViewerProvider,
|
|
FidelityProviderConfigError,
|
|
fidelity_holdings_to_snapshot,
|
|
gains_offset_delta_activity,
|
|
)
|
|
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)
|
|
|
|
|
|
# -- delta-shaped gains offset (the monthly accumulation mechanism) --
|
|
|
|
|
|
def _holdings_summing_to(total: Decimal) -> list:
|
|
from broker_sync.providers.parsers.fidelity import FidelityHolding
|
|
return [FidelityHolding(
|
|
fund_code="KDOA", fund_name="Test", units=Decimal("100"),
|
|
unit_price=total / Decimal("100"), currency="GBP", total_value=total,
|
|
units_by_source={},
|
|
)]
|
|
|
|
|
|
def test_gains_delta_emits_deposit_when_gain_exceeds_prior_offset() -> None:
|
|
# pot £145k, real contrib £102k → current gain £43k; prior offset £35k
|
|
# → delta = +£8k
|
|
activity = gains_offset_delta_activity(
|
|
holdings=_holdings_summing_to(Decimal("145000")),
|
|
total_real_contribution=Decimal("102000"),
|
|
prior_offset_cumulative=Decimal("35000"),
|
|
as_of=datetime(2026, 5, 17, tzinfo=UTC),
|
|
)
|
|
assert activity is not None
|
|
assert activity.activity_type == ActivityType.DEPOSIT
|
|
assert activity.amount == Decimal("8000")
|
|
assert activity.external_id == "fidelity:gains-delta:2026-05-17"
|
|
assert "unrealised-gains-offset" in (activity.notes or "")
|
|
|
|
|
|
def test_gains_delta_emits_withdrawal_on_market_drop() -> None:
|
|
# pot dropped: current gain £30k, prior offset £35k → delta = -£5k
|
|
activity = gains_offset_delta_activity(
|
|
holdings=_holdings_summing_to(Decimal("132000")),
|
|
total_real_contribution=Decimal("102000"),
|
|
prior_offset_cumulative=Decimal("35000"),
|
|
as_of=datetime(2026, 5, 17, tzinfo=UTC),
|
|
)
|
|
assert activity is not None
|
|
assert activity.activity_type == ActivityType.WITHDRAWAL
|
|
assert activity.amount == Decimal("5000")
|
|
|
|
|
|
def test_gains_delta_suppressed_below_minimum() -> None:
|
|
# delta ~£0.20, below the £0.50 min — skip emission to avoid noise.
|
|
activity = gains_offset_delta_activity(
|
|
holdings=_holdings_summing_to(Decimal("137000.20")),
|
|
total_real_contribution=Decimal("102000"),
|
|
prior_offset_cumulative=Decimal("35000"),
|
|
as_of=datetime(2026, 5, 17, tzinfo=UTC),
|
|
)
|
|
assert activity is None
|
|
|
|
|
|
def test_gains_delta_none_when_no_holdings() -> None:
|
|
assert gains_offset_delta_activity(
|
|
holdings=[], total_real_contribution=Decimal("0"),
|
|
prior_offset_cumulative=Decimal("0"),
|
|
as_of=datetime(2026, 5, 17, tzinfo=UTC),
|
|
) is None
|