Initial scaffold + canonical Activity model

Context
-------
New connector suite that syncs UK brokerage activity (Trading212,
InvestEngine, Schwab email-parsed, CSV drop-folder) into Wealthfolio.
Lives outside finance/ intentionally — finance/ is untouched per the
plan at ~/.claude/plans/let-s-work-on-linking-temporal-valiant.md.

This change
-----------
- Poetry project with httpx, typer, bs4, dev tools (pytest, mypy strict,
  ruff, yapf).
- Canonical Activity + Account models with the 6 UK tax wrappers
  (ISA/SIPP/GIA/LISA/JISA/WORKPLACE_PENSION) and the 12 Wealthfolio
  activity types from docs/activities/activity-types.md on the upstream.
- Validation invariants: BUY/SELL need qty+price, DIVIDEND/DEPOSIT/etc
  need amount — raises early so providers can't silently emit broken
  rows.
- to_wealthfolio_csv_row() shape matches Wealthfolio's CSV import;
  primary sink path per the plan.

Test plan
---------
## Automated
- poetry run pytest -q  →  7 passed in 0.03s
- poetry run mypy broker_sync tests  →  Success: no issues found in 4 source files
- poetry run ruff check .  →  All checks passed!
- poetry run yapf --diff --recursive broker_sync tests  →  no diff

## Manual Verification
Not applicable — pure data model, no runtime behaviour.

Closes: code-thw.1
This commit is contained in:
Viktor Barzin 2026-04-17 19:16:11 +00:00
commit a2aa7ec486
7 changed files with 958 additions and 0 deletions

126
tests/test_models.py Normal file
View file

@ -0,0 +1,126 @@
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from broker_sync.models import (
Account,
AccountType,
Activity,
ActivityType,
FxRateSource,
)
def test_account_type_covers_uk_wrappers() -> None:
assert {
AccountType.ISA,
AccountType.SIPP,
AccountType.GIA,
AccountType.LISA,
AccountType.JISA,
AccountType.WORKPLACE_PENSION,
} <= set(AccountType)
def test_activity_type_covers_wealthfolio_types() -> None:
# Mirrors Wealthfolio docs/activities/activity-types.md
for name in (
"BUY",
"SELL",
"DIVIDEND",
"INTEREST",
"DEPOSIT",
"WITHDRAWAL",
"TRANSFER_IN",
"TRANSFER_OUT",
"CONVERSION_IN",
"CONVERSION_OUT",
"FEE",
"TAX",
):
assert hasattr(ActivityType, name), f"missing {name}"
def test_activity_minimal_construct() -> None:
a = Activity(
external_id="t212:order:1",
account_id="t212-isa",
account_type=AccountType.ISA,
date=datetime(2026, 1, 15, tzinfo=UTC),
activity_type=ActivityType.BUY,
symbol="VUAG",
quantity=Decimal("10"),
unit_price=Decimal("85.5"),
currency="GBP",
)
assert a.external_id == "t212:order:1"
assert a.fee == Decimal("0")
assert a.fx_rate_source is None
def test_buy_requires_qty_and_price() -> None:
with pytest.raises(ValueError, match="BUY.*quantity"):
Activity(
external_id="x",
account_id="a",
account_type=AccountType.GIA,
date=datetime(2026, 1, 1, tzinfo=UTC),
activity_type=ActivityType.BUY,
symbol="VUAG",
currency="GBP",
)
def test_dividend_requires_amount() -> None:
with pytest.raises(ValueError, match="DIVIDEND.*amount"):
Activity(
external_id="x",
account_id="a",
account_type=AccountType.GIA,
date=datetime(2026, 1, 1, tzinfo=UTC),
activity_type=ActivityType.DIVIDEND,
currency="GBP",
)
def test_wealthfolio_csv_row_shape() -> None:
a = Activity(
external_id="ie:2026-04:0001",
account_id="ie-isa",
account_type=AccountType.ISA,
date=datetime(2026, 4, 1, 10, 30, tzinfo=UTC),
activity_type=ActivityType.BUY,
symbol="VWRP",
quantity=Decimal("2.5"),
unit_price=Decimal("110.00"),
currency="GBP",
fee=Decimal("0.50"),
amount_gbp=Decimal("275.50"),
fx_rate_gbp=Decimal("1.0"),
fx_rate_source=FxRateSource.ECB_LIVE,
notes="sync:invest-engine:ie:2026-04:0001",
)
row = a.to_wealthfolio_csv_row()
# Wealthfolio activity import columns (from docs):
# date, activity_type, symbol, quantity, unit_price, currency, fee, amount, notes, ...
assert row["date"] == "2026-04-01T10:30:00+00:00"
assert row["activity_type"] == "BUY"
assert row["symbol"] == "VWRP"
assert row["quantity"] == "2.5"
assert row["unit_price"] == "110.00"
assert row["currency"] == "GBP"
assert row["fee"] == "0.50"
assert row["notes"] == "sync:invest-engine:ie:2026-04:0001"
def test_account_construct() -> None:
acc = Account(
id="t212-isa",
name="Trading212 ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="trading212",
)
assert acc.id == "t212-isa"
assert acc.account_type is AccountType.ISA