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
|
|
@ -1,19 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
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.models import Account, AccountType
|
||||
from broker_sync.providers.fidelity_planviewer import (
|
||||
ACCOUNT_ID,
|
||||
FidelityCreds,
|
||||
FidelityPlanViewerProvider,
|
||||
FidelityProviderConfigError,
|
||||
_gains_offset_activity,
|
||||
fidelity_holdings_to_snapshot,
|
||||
)
|
||||
from broker_sync.providers.parsers.fidelity import (
|
||||
parse_transactions_html,
|
||||
|
|
@ -96,21 +96,53 @@ def test_parse_valuation_fixture() -> None:
|
|||
assert set(h.units_by_source.keys()) >= {"SASC", "ERXS"}
|
||||
|
||||
|
||||
def test_gains_offset_emits_deposit_when_pot_exceeds_contributions() -> None:
|
||||
def test_holdings_to_snapshot_real_fixture() -> None:
|
||||
html = (_FIXTURES / "transactions-full.html").read_text()
|
||||
valuation = json.loads((_FIXTURES / "valuation.json").read_text())
|
||||
txs = parse_transactions_html(html)
|
||||
holdings = parse_valuation_json(valuation)
|
||||
as_of = datetime(2026, 4, 18, tzinfo=UTC)
|
||||
offset = _gains_offset_activity(holdings, txs, as_of)
|
||||
assert offset is not None
|
||||
assert offset.activity_type in (ActivityType.DEPOSIT, ActivityType.WITHDRAWAL)
|
||||
assert offset.amount is not None and offset.amount > 0
|
||||
assert offset.external_id == "fidelity:gains:2026-04-18"
|
||||
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_gains_offset_none_when_no_holdings() -> None:
|
||||
assert _gains_offset_activity(
|
||||
holdings=[], transactions=[],
|
||||
as_of=datetime(2026, 4, 18, tzinfo=UTC),
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -12,6 +12,9 @@ import pytest
|
|||
from broker_sync.models import Account, AccountType, Activity, ActivityType
|
||||
from broker_sync.sinks.wealthfolio import (
|
||||
ImportValidationError,
|
||||
ManualSnapshotPayload,
|
||||
SnapshotPosition,
|
||||
WealthfolioError,
|
||||
WealthfolioSink,
|
||||
WealthfolioUnauthorizedError,
|
||||
)
|
||||
|
|
@ -274,3 +277,99 @@ async def test_import_halts_on_validation_failure(tmp_path: Path) -> None:
|
|||
with pytest.raises(ImportValidationError, match="unknown symbol"):
|
||||
await sink.import_activities([_buy()])
|
||||
assert calls == ["/api/v1/activities/import/check"] # real import never hit
|
||||
|
||||
|
||||
# -- Manual snapshot import (Fidelity path) --
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_manual_snapshots_serialises_decimals_and_calls_endpoint(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sp = tmp_path / "s.json"
|
||||
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
||||
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/api/v1/snapshots/import":
|
||||
seen["body"] = json.loads(req.content)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"snapshotsImported": 1, "snapshotsFailed": 0, "errors": []},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
sink = _client(httpx.MockTransport(handler), sp)
|
||||
snapshot = ManualSnapshotPayload(
|
||||
date=date(2026, 5, 16),
|
||||
currency="GBP",
|
||||
positions=[
|
||||
SnapshotPosition(
|
||||
symbol="KDOA",
|
||||
quantity=Decimal("4200.5"),
|
||||
average_cost=Decimal("24.29"),
|
||||
total_cost_basis=Decimal("102004.15"),
|
||||
currency="GBP",
|
||||
),
|
||||
],
|
||||
cash_balances={"GBP": Decimal(0)},
|
||||
)
|
||||
result = await sink.push_manual_snapshots(
|
||||
account_id="a7d6208d-2bd6-4f85-bf54-b77984c78234",
|
||||
snapshots=[snapshot],
|
||||
)
|
||||
assert result["snapshotsImported"] == 1
|
||||
# Wire format: numeric fields are STRINGS (Decimal.__format__('f'))
|
||||
body = seen["body"]
|
||||
assert body["accountId"] == "a7d6208d-2bd6-4f85-bf54-b77984c78234"
|
||||
pos = body["snapshots"][0]["positions"][0]
|
||||
assert pos == {
|
||||
"symbol": "KDOA",
|
||||
"quantity": "4200.5",
|
||||
"averageCost": "24.29",
|
||||
"totalCostBasis": "102004.15",
|
||||
"currency": "GBP",
|
||||
}
|
||||
assert body["snapshots"][0]["cashBalances"] == {"GBP": "0"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_manual_snapshots_raises_on_partial_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sp = tmp_path / "s.json"
|
||||
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
||||
|
||||
async def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"snapshotsImported": 0,
|
||||
"snapshotsFailed": 1,
|
||||
"errors": [{"row": 0, "msg": "bad symbol"}],
|
||||
},
|
||||
)
|
||||
|
||||
sink = _client(httpx.MockTransport(handler), sp)
|
||||
snapshot = ManualSnapshotPayload(
|
||||
date=date(2026, 5, 16), currency="GBP",
|
||||
positions=[], cash_balances={},
|
||||
)
|
||||
with pytest.raises(WealthfolioError, match="bad symbol"):
|
||||
await sink.push_manual_snapshots(account_id="acct", snapshots=[snapshot])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_manual_snapshots_short_circuits_on_empty(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
sp = tmp_path / "s.json"
|
||||
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
|
||||
|
||||
async def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError(f"unexpected request: {req.method} {req.url.path}")
|
||||
|
||||
sink = _client(httpx.MockTransport(handler), sp)
|
||||
result = await sink.push_manual_snapshots(account_id="acct", snapshots=[])
|
||||
assert result["snapshotsImported"] == 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue