broker-sync/tests/providers/parsers/test_invest_engine.py

45 lines
1.5 KiB
Python
Raw Normal View History

Add InvestEngine email parser — RFC 2822 v1/v2 line format Context: The old finance/ app had a 324-line IE message parser with four line-based variants (v1/v2/v3/v4) plus an HTML strategy and a CSV fallback. Port into broker-sync so we can consume IE trade confirmation emails as a backup to the live HTTP client (Phase 2b) while IE's public API remains Bearer-only. The upstream parser emits storage.model.Position; we emit canonical Activity with the broker-sync invariants: account_id="invest-engine-primary" (sink remaps to Wealthfolio UUID), account_type=ISA, currency=GBP, and external_id="invest-engine:<fingerprint>" where the fingerprint is a SHA-256 of (date|symbol|quantity|unit_price) — deterministic so repeat imports of the same email dedup at the sync-record layer. This change: - Top-level `parse_invest_engine_email(raw_email: bytes) -> list[Activity]` extracts the text/plain body from an RFC 2822 message and dispatches to the line-based parser. - `_parse_rfc2822_lines(body)` tries the v2 layout first (newer IE format where `Date: DD Month` is on line 2 and the year on line 3), then the v1 layout (where the day alone is on line 2 and `Month YYYY` on line 3). v3 and v4 variants are re-added in a follow-up if we find fixtures where they matter — initial fixture coverage hits v2. - Drops the upstream `_ticker_post_processing` VUAG→VUAG.L hack. Wealthfolio's /import/check endpoint resolves exchange suffixes; the Trading212 provider also emits suffix-free tickers (e.g. `VUAG`), so staying consistent avoids double-mapping. - Notes field records the parse-strategy tag ("rfc2822-v2") plus the matched line for debugging. Test plan: poetry run pytest tests/providers/parsers/ -q → 3 passed in 0.03s poetry run mypy broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py → Success: no issues found in 2 source files poetry run ruff check broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py → All checks passed! poetry run yapf --diff broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py → clean (no diff) Manual verification: load the fixture email, call the parser, inspect the returned Activity has symbol=VUAG, quantity=59.539562, unit_price=60.46, date=2023-01-17, external_id starts with invest-engine:.
2026-04-17 21:49:52 +00:00
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from broker_sync.models import AccountType, ActivityType
from broker_sync.providers.parsers.invest_engine import parse_invest_engine_email
_FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "invest_engine"
def _load(name: str) -> bytes:
return (_FIXTURES / name).read_bytes()
# -- RFC 2822 body (v2-style, single BUY) --
def test_rfc2822_single_buy_parses_to_one_activity() -> None:
activities = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))
assert len(activities) == 1
a = activities[0]
assert a.activity_type is ActivityType.BUY
assert a.symbol == "VUAG"
assert a.quantity == Decimal("59.539562")
assert a.unit_price == Decimal("60.46")
assert a.currency == "GBP"
assert a.date == datetime(2023, 1, 17)
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
def test_rfc2822_external_id_is_deterministic() -> None:
a1 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
a2 = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
assert a1.external_id == a2.external_id
assert a1.external_id.startswith("invest-engine:")
def test_rfc2822_notes_record_parse_strategy() -> None:
a = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
assert a.notes is not None
assert "rfc2822" in a.notes