Compare commits

..

47 commits

Author SHA1 Message Date
Viktor Barzin
b632d951e4 ci: move image build off-infra to GHA -> ghcr (ADR-0002)
Some checks failed
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
Build and Push / notify-failure (push) Has been cancelled
Generated by infra/scripts/offinfra-onboard: GHA builds+tests on the
GitHub mirror, pushes ghcr.io/viktorbarzin/wealthfolio-sync, then triggers the
Woodpecker deploy (repo 0). Old in-cluster build pipeline
removed: .woodpecker/build.yml

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 01:40:07 +00:00
Viktor Barzin
0d23487608 imap: skip InvestEngine by default; opt back in via INCLUDE env
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
ci/woodpecker/push/build Pipeline was successful
Post-mortem 2026-05-27: 39 IMAP-source IE BUYs + their cash-flow
DEPOSITs were re-inserted into Wealthfolio at 09:22:18 UTC, exactly
the rows the £252k dedup removed the previous day. The cron's
BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS=invest-engine env var did its job
(cron logged ie_skipped=53), but some other entry point — kubectl run,
poetry run on the devvm, or a sibling agent session — ran the IMAP
ingest WITHOUT that env. The opt-out was a foot-gun.

This change makes the IE-via-IMAP safety STRUCTURAL: `invest-engine`
is in the default exclude set inside _resolve_excluded_providers().
Any code path now skips IE unless the caller explicitly sets
`BROKER_SYNC_IMAP_INCLUDE_PROVIDERS=invest-engine`. The
`BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS` env still works (additive) for
forward-compat in case Schwab etc. ever need similar treatment.

INCLUDE wins over both the default exclude set and EXCLUDE env.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 17:24:54 +00:00
Viktor Barzin
3427f5c9e1 ibkr: emit ibkr_cash_balance{currency, account} per CashReport row
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline failed
Each daily run now pushes one Pushgateway metric per currency row from
the Flex Activity Query's CashReport section (typically BASE_SUMMARY
aggregate + one row per held currency). Makes dormant-account balance
checks trivial and adds a Grafana surface for cash drift alerting.

Requires the Activity Flex Query in IBKR Client Portal to have the
CashReport section enabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:03:41 +00:00
Viktor Barzin
17c2a69c6c parsers/schwab: emit paired BUY for recent SELL (vest synthesis)
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
Schwab Stock Plan Services doesn't email vest-release confirmations to
the employee inbox — only the same-day-sell trade-executed alert lands.
The vest itself was invisible to broker-sync, so the META cadence
panel in the wealth dashboard has been missing the May 2026 vest BUY
and would keep missing every future vest.

Synthesis: when a SELL email's trade date is on/after the configured
boundary (default 2026-04-01), also emit a paired BUY with identical
date/qty/price/symbol. Notes link the pair via the SELL's external_id.
Verified true across 14 historical vests — 100% same-day-sell pattern,
SELL qty == vest qty.

Boundary stops the synthesis from back-filling vests prior to 2026-04
which already have csv-sourced BUY rows in Wealthfolio from the
historical one-shot backfill (last vest 2026-02-18). The csv BUYs and
inferred BUYs have distinct external_ids, so re-running against old
emails would double-count without this guard. Override via env var
`SCHWAB_VEST_INFER_FROM_DATE=yyyy-mm-dd` on the broker-sync-imap cron.

Tests: 4 new cases — recent SELL pairs, old SELL doesn't pair, env
override works, BUY-direction emails (rare) don't get paired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:02:07 +00:00
Viktor Barzin
abf9fa7cb5 parsers/schwab: drop dead vest-release path
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
The _parse_vest_release path and _VEST_*_RE regexes never matched a
real email in 4 years of inbox history (2022-08 → 2026-05, 188 Schwab
emails surveyed). Schwab Stock Plan Services does not email release
confirmations to the employee address for the workplace account — only
the sell-to-cover trade-executed alert lands. Vest data must come from
the META payslip via payslip-ingest (tracked as code-fqgr).

Removed:
- _VEST_SUBJECT_RE + 5 _VEST_*_RE regexes (heuristic, never validated)
- _parse_vest_release function
- VestParseResult dataclass
- parse_schwab_email_full wrapper
- _search_group helper (only used by vest path)
- 3 dead tests + the _VEST_RELEASE fixture

Kept models.VestEvent — the payslip→Wealthfolio sink in code-fqgr will
need it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:40:56 +00:00
Viktor Barzin
bb9e0d4567 docs(ibkr): use Last 365 Days (no 'Last 90 Days' preset in IBKR UI)
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
2026-05-27 09:28:42 +00:00
Viktor Barzin
ceb652b623 ibkr: use IBKR account number as the canonical Account.id
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was canceled
Bug: provider passed the WF UUID as Account.id. ensure_account looks up
existing accounts by (provider, providerAccountId=Account.id), so the
WF-UUID-as-providerAccountId would never match the manually-created
account (which has providerAccountId=U13279690), causing the pipeline
to create a duplicate WF account on every cron run.

Fix: Account.id is now the IBKR account number (U13279690) throughout.
The pipeline's _ensure_accounts() resolves it to the WF UUID via the
canonical (provider, providerAccountId) lookup; activities are remapped
before import. CLI no longer takes the WF UUID — derives it post-import
via a cheap idempotent ensure_account call for the reconciliation step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:18:42 +00:00
Viktor Barzin
30af5fe2c9 docs(ibkr): change Flex date range from Last Business Day → Last 90 Days
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was canceled
Trailing window backed by SyncRecordStore dedup is strictly better than
a single-day window — a single missed cron run with Last Business Day
loses that day's activity permanently. SyncRecordStore is keyed by
ibkr:trade:<tradeID> / ibkr:cash:<transactionID>, so overlapping pulls
are no-ops.

Caught during the brainstorming review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:11:57 +00:00
Viktor Barzin
0ab069349f sinks/wealthfolio: treat duplicates as success in import-summary check
The IMAP cronjob re-processes the full mailbox window on every run, so
on steady-state runs all activities come back tagged duplicate=N. The
existing logic raises ImportValidationError whenever imported_n < total_n,
which makes the cron exit 1 (and the Job is reported FAILED) even though
the data path is healthy.

Treat (imported + duplicates) as "accounted for". Only raise when rows
go missing entirely (silently dropped / validation rejected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:52:11 +00:00
Viktor Barzin
2fb1fbbdd8 docs: add IBKR provider runbook
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
2026-05-26 22:34:46 +00:00
Viktor Barzin
a4dab03bc5 cli: add 'broker-sync ibkr' command (Flex pull + import + reconcile + metrics)
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was canceled
2026-05-26 22:29:44 +00:00
Viktor Barzin
e83c5a0a8f ibkr: add Flex provider — Trade/Cash mapping + OpenPositions snapshot
Maps Trades (BUY/SELL) and CashTransactions (DIVIDEND, TAX, INTEREST, FEE,
DEPOSIT, WITHDRAWAL) from an IBKR Flex Activity Query to broker-sync
Activity objects. Adds canonical_symbol helper (LSE → .L suffix when
exchange=LSE* or currency=GBP). Exposes OpenPositions for the
reconciliation step that runs at the CLI layer.

Guards against wrong-account writes by checking
stmt.accountId == IBKR_ACCOUNT_ID_UPSTREAM before yielding any activities.

13 unit tests cover all the mappings + the mismatch guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:28:35 +00:00
Viktor Barzin
882415464e wealthfolio: add compute_position_qty for broker reconciliation 2026-05-26 22:26:24 +00:00
Viktor Barzin
975c3b4bf7 metrics: add Pushgateway client for broker-sync providers 2026-05-26 22:24:55 +00:00
Viktor Barzin
82797908b7 test: add IBKR Flex XML fixture (3 trades, 2 cash txns, 2 positions) 2026-05-26 22:23:20 +00:00
Viktor Barzin
7cba540c37 deps: add ibflex with web extras for IBKR Flex Web Service ingestion 2026-05-26 22:21:54 +00:00
Viktor Barzin
c271d5101c docs: add IBKR Flex ingestion implementation plan
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
15 task plan covering deps → fixtures → metrics → sink helper →
provider mapping → CLI command → CI/image → Vault setup → Terraform
CronJob → smoke test → provider runbook → 7-day soak.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:19:54 +00:00
Viktor Barzin
ca5f98f771 docs: add IBKR Flex ingestion design spec
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline failed
Adds a daily IBKR Flex Web Service → Wealthfolio ingestion path alongside
the existing T212 / IE / Fidelity providers. Uses the ibflex library;
mandatory broker-vs-WF position reconciliation built in from day one to
prevent the silent-drift class of bug we hit with InvestEngine on 2026-05-26.

Account is currently empty so the initial backfill step is a no-op until
the first IBKR trade lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:52:52 +00:00
Viktor Barzin
e6ef1fce97 test: drop redundant quotes on MonkeyPatch annotation
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline was successful
`from __future__ import annotations` makes the quoting unnecessary and
ruff UP037 flags it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:20:12 +00:00
Viktor Barzin
fe35c8e826 test: fix mypy errors in IE-exclude test
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline failed
- annotate monkeypatch fixture as pytest.MonkeyPatch
- import invest_engine parser module directly instead of via imap_mod.ie_parser
  (mypy's strict "no implicit re-export" rule trips on the indirection)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:18:55 +00:00
Viktor Barzin
68d4832c2e imap: skip InvestEngine emails via BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS
Some checks failed
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
ci/woodpecker/push/build Pipeline failed
The IMAP IE parser and the bearer-token IE API path generate different
external_ids for the same fill, so running both produces duplicate BUYs
in Wealthfolio. With IE now served by the API path (broker-sync invest-engine),
we keep the IMAP path live for Schwab and gate IE off via env var.

Setting BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS=invest-engine on the imap CronJob
stops new dupes; Schwab routing is unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:16:28 +00:00
d5dbeb96af tests: type the FidelityHolding factory list to satisfy CI mypy
CI runs mypy on both broker_sync/ and tests/, with stricter
'Missing type arguments for generic type' enforcement. Local
mypy was only scoped to broker_sync/. Annotate the test helper
with list[FidelityHolding]; lift the import to module-level.
2026-05-22 14:54:06 +00:00
d860aef927 imap: accept Schwab subdomain senders (donotreply@mail.schwab.com)
Real Schwab trade-execution emails come from donotreply@mail.schwab.com,
not the root @schwab.com domain. The existing matcher's endswith("@schwab.com")
guard rejected these, silently skipping the May 2026 RSU vest's
same-day-sell confirmation. Extend the matcher to also accept any
*.schwab.com subdomain.

Added test_schwab_subdomain_sender_matches; full suite green.
2026-05-22 14:41:09 +00:00
98c4729622 fidelity: replace snapshot-push with delta gains-offset DEPOSITs
Some checks failed
ci/woodpecker/push/build Pipeline failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
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.
2026-05-17 00:35:17 +00:00
c9c0310733 fidelity: snapshot push needs WF account UUID, not logical id
/api/v1/snapshots/import lookups the account by Wealthfolio's own
UUID; passing our provider-side logical id ('fidelity-workplace-pension')
returns 400 'Database operation failed: Record not found'. Resolve
via sink.ensure_account() which the pipeline already runs idempotently,
then pass the returned UUID into push_manual_snapshots().
2026-05-16 23:47:49 +00:00
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
Viktor Barzin
5adc4a7ba4 [ci] deploy.yml: manual-only — push events don't set IMAGE_TAG
Some checks failed
ci/woodpecker/push/build Pipeline was successful
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
2026-05-07 23:25:28 +00:00
Viktor Barzin
f4a4c8892f trigger pipeline
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
2026-05-07 22:47:37 +00:00
Viktor Barzin
dfee29fda7 [ci] Add Woodpecker build pushing to forgejo.viktorbarzin.me/viktor/wealthfolio-sync
Some checks failed
ci/woodpecker/manual/deploy Pipeline failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
Companion to the existing GHA pipeline that pushes broker-sync to
DockerHub. The Woodpecker build pushes to Forgejo as wealthfolio-sync
(image name kept to match the existing infra/stacks/wealthfolio/main.tf
CronJob reference, which has been broken since registry-private lost
the image).
2026-05-07 22:33:29 +00:00
Viktor Barzin
1d1e20b72b schwab: detect vest-confirmation emails + emit VestEvent
Some checks are pending
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
Extends parse_schwab_email to handle Schwab's RSU Release Confirmation
emails alongside the existing trade confirmations. Adds:

- `VestEvent` dataclass in models.py — carries vest_date, ticker,
  shares_vested, shares_sold_to_cover, fmv_at_vest_usd, tax_withheld_usd.
  Written to payslip_ingest.rsu_vest_events by a postgres sink (pending
  a real email fixture + cross-service DB grant).
- `parse_schwab_email_full()` — new entry point returning both
  `list[Activity]` and `VestEvent | None`. The legacy
  `parse_schwab_email()` shape is preserved for existing callers.
- Vest-release dispatch heuristic: HTML body mentions "Release
  Confirmation" / "Award Vesting" / "RSU Release". On match, extract
  vest fields via label regexes; the full vest becomes a BUY Activity
  and the sell-to-cover slice becomes a SELL Activity at the same FMV
  (net zero cash on the day). Gross vest + sell-to-cover returned so
  Wealthfolio gets the full portfolio picture.
- Tests: 3 new (vest roundtrip, unparseable-vest safety, legacy shape
  preserved); existing 6 unchanged.

The regex heuristics will need tightening once a real email sample
exists — the HTML structure observed in public Schwab emails may
differ in material ways. For now, unmatched vest bodies return
empty-result (no Activity, no VestEvent) rather than crashing the
IMAP batch.

Part of: code-860
2026-04-19 18:27:58 +00:00
Viktor Barzin
6f3bcea23e ci: fix ruff E501 + mypy None-comparison warning
test_imap.py:49 — one-line comment ran past the 100-char line limit
introduced in commit c830856. Split the "£20,000 cap" note onto its
own line above the call.

test_fidelity_planviewer.py:108 — mypy flagged `offset.amount > 0`
where amount is typed Decimal | None. Added an explicit `is not None`
guard; runtime behaviour unchanged (we already check offset is not
None two lines earlier).

$ poetry run ruff check . → All checks passed!
$ poetry run mypy broker_sync tests → Success: no issues found in 43 source files
$ poetry run pytest -q → 133 passed, 1 skipped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:52:38 +00:00
Viktor Barzin
6450201af0 pipeline: emit matching DEPOSIT/WITHDRAWAL for every BUY/SELL
## Context

The 2026-04-18 reconciliation ended with Wealthfolio's historical Net
Worth chart showing cliff-jumps on 5 dates — the single-day lump cash
offsets we'd posted to "zero out" phantom cash. An operational fix
replaced those 6 lumps with 231 per-BUY/SELL matched DEPOSIT/WITHDRAWAL
rows (see code-r9n note). That made the chart smooth — but only for
today's data. Any future broker-sync run would re-introduce phantom
cash because providers emit BUY/SELL only; nothing on the cash side.

This commit bakes the match into the pipeline so **future syncs
self-balance cash at import time** and the chart stays smooth.

## This change

- broker_sync/pipeline.py
  - New _matched_cash_flow(a): returns a DEPOSIT for a BUY (amount =
    qty * unit_price + fee) or a WITHDRAWAL for a SELL (amount =
    qty * unit_price - fee). Returns None for every other activity
    type — DEPOSIT/WITHDRAWAL/DIVIDEND/etc. already touch cash
    directly. The synthetic activity carries a deterministic
    external_id `cash-flow-match:<buy|sell>:<original external_id>`
    so SyncRecordStore dedup handles idempotency across runs.
  - New _with_cash_flow_match(a): expand helper — returns [a] or
    [a, match]. Pure, testable.
  - sync_provider_to_wealthfolio loops over the expansion, so each
    activity may now contribute up to two rows to the batch. `fetched`
    still counts provider-side activities only; `new_after_dedup` +
    `imported` + `failed` count expanded rows.
- tests/test_pipeline.py
  - Updated two existing pipeline integration tests to reflect the
    now-larger batch shape (3 BUYs become 6 rows after expansion).
  - 5 new unit tests for the helpers: BUY → DEPOSIT with fee,
    SELL → WITHDRAWAL net of fee, DEPOSIT/WITHDRAWAL/DIVIDEND pass
    through, zero-amount trades skipped, _with_cash_flow_match
    returns the right cardinality.

## What is NOT in this change

- Provider-level opt-out (e.g., Provider.emits_matching_cash_flow =
  True). No current provider emits real cash flows alongside trades
  (Trading212 only calls /orders, not /transactions), so the default
  "always match" is safe. If we ever wire a provider that pulls real
  bank-transfer dates, add the opt-out then.
- Retroactive cleanup of already-imported WF accounts — already done
  operationally today.

## Verification

### Automated

$ poetry run pytest tests/test_pipeline.py -v
7 passed in 0.40s

$ poetry run pytest -q
133 passed, 1 skipped in 8.58s

$ poetry run mypy broker_sync/pipeline.py tests/test_pipeline.py
Success: no issues found in 2 source files

$ poetry run ruff check broker_sync/pipeline.py tests/test_pipeline.py
All checks passed!

### Manual — next sync

Once this image ships and broker-sync-trading212 / broker-sync-imap /
broker-sync-fidelity run, confirm:
1. kubectl -n broker-sync logs job/<next-run> → fetched=N new=2N
   imported=2N failed=0 (doubled due to matches).
2. WF /api/v1/holdings?accountId=<uuid> → cash ≈ £0 for every currency
   after import.
3. Net Worth chart has no new cliff-jumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:12:49 +00:00
Viktor Barzin
7c9be544dc fidelity-planviewer: bake Chromium into the image for headless Playwright
## Context

The Fidelity provider (commit 804e6a8) drives headless Chromium via
Playwright to refresh the PlanViewer session cookie jar and scrape the
Struts2 transaction history page. The image needs both the Chromium
runtime and the Debian system libs Chromium dynamic-links against.

## This change

- Adds Playwright's documented Debian 12 dependency set
  (fonts-liberation, libnss3, libxkbcommon0, xvfb, etc.).
- Creates /app/.playwright-browsers owned by the broker user so the
  non-root process can write the Chromium install, and runs `playwright
  install chromium` as that user so the browser lands in the right
  cache path (PLAYWRIGHT_BROWSERS_PATH=/app/.playwright-browsers).
- Image size will grow by ~300MB (Chromium headless shell is ~110MB
  compressed, plus libs). Acceptable — broker-sync runs once a day so
  pull cost is a one-shot.

## What is NOT in this change

- Terraform CronJob / monitoring — separate commit in the infra repo.

## Verification

$ docker build -t broker-sync:test . → (will run in CI)
$ docker run --rm broker-sync:test fidelity-seed --help → shows the
  CLI help (can't actually run fidelity-seed headlessly).
$ poetry run pytest -q (local) → 128 passed, 1 skipped.

Reproduce locally:
1. docker build -t broker-sync:fidelity-test .
2. docker run --rm -v $PWD/tests/fixtures/fidelity:/data broker-sync:fidelity-test \
     python -c "from playwright.sync_api import sync_playwright; \
                with sync_playwright() as p: b = p.chromium.launch(); b.close(); print('ok')"
3. Expected: "ok" — Chromium launches successfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:50:54 +00:00
Viktor Barzin
804e6a89de fidelity-planviewer: wire provider to real PlanViewer session + JSON API
## Context

Prior commit 832732a scaffolded the provider with a stub fetch() that
raised FidelityProviderConfigError. This commit replaces the stub with
the end-to-end ingest flow, validated against the real PlanViewer site
during a live login session on 2026-04-18.

Fidelity UK PlanViewer mixes a legacy Struts2 HTML app
(www.planviewer.fidelity.co.uk) with a React SPA at
pv.planviewer.fidelity.co.uk. Authentication is PingFederate OAuth2 at
id.fidelity.co.uk — password + memorable word + SMS OTP, with a
remember-device cookie that keeps the session alive for weeks. The
transaction history is server-rendered HTML at DisplayMyPlanMemberTransHist.action;
current fund holdings come from the DisplayValuation.action JSON XHR.

Both live behind the same cookie jar, so one Playwright session (seeded
interactively once, kept alive via storage_state) can scrape both.

## This change

- broker_sync/providers/parsers/fidelity.py (NEW)
  - parse_transactions_html: extracts cash-impacting rows from the
    #myplan_member_transhist_support table, skips Bulk Switches (no cash
    movement), emits FidelityCashTx with deterministic external_id for
    dedup.
  - parse_valuation_json: lifts fund code + name + units + price +
    contribution-type breakdown from the JSON payload.
- broker_sync/providers/fidelity_planviewer.py (REWRITTEN)
  - FidelityPlanViewerProvider.fetch() now loads storage_state, boots
    headless Chromium, navigates landing → main page (to hydrate the
    SPA session + capture DisplayValuation XHR) → transactions page
    with a wide 01 Jan 1990 → today window. Raises FidelitySessionError
    if PlanViewer shows the 15-min idle page or redirects back to
    id.fidelity.co.uk.
  - _gains_offset_activity emits a synthetic DEPOSIT/WITHDRAWAL with a
    date-keyed external_id so WF Net Worth reconciles to the
    Fidelity-reported pot value without stacking duplicates across
    monthly runs.
  - Rolls storage_state back to disk after each run, extending session
    TTL.
- tests/providers/test_fidelity_planviewer.py (EXTENDED)
  - 8 tests against a real captured fixture: account shape, guard on
    missing storage_state, full-fixture round-trip (51 txs summing to
    £102,004.15), Bulk Switch filtered, deterministic external_id,
    valuation parse with fund-code resolution, gains-offset direction
    + skip-when-empty.
- tests/fixtures/fidelity/transactions-full.html + valuation.json (NEW)
  - Sanitised captures from the 2026-04-18 live session.

## What is NOT in this change

- CronJob + Vault secret wiring + Prometheus alert in
  infra/stacks/broker-sync/main.tf — next commit.
- Dockerfile Chromium install — next commit.
- The scrape-and-import was already done manually (51 activities +
  1 gains offset imported into WF account a7d6208d); this commit
  productionises the code path so the monthly cron can do the same.

## Verification

### Automated

$ poetry run pytest tests/providers/test_fidelity_planviewer.py -v
8 passed in 0.88s

$ poetry run pytest -q
128 passed, 1 skipped in 1.41s

$ poetry run mypy broker_sync/providers/fidelity_planviewer.py broker_sync/providers/parsers/fidelity.py
Success: no issues found in 2 source files

$ poetry run ruff check broker_sync/providers/fidelity_planviewer.py broker_sync/providers/parsers/fidelity.py
All checks passed!

### Manual verification (2026-04-18 live run)

1. poetry run broker-sync fidelity-seed (headed browser + SMS OTP) —
   captured storage_state, staged to Vault.
2. Inline import script hit the same code paths the provider now runs;
   52 activities imported into a new WF WORKPLACE_PENSION account, WF
   Net Worth jumped from £865,358 → £1,003,083.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:47:38 +00:00
Viktor Barzin
832732a419 fidelity-planviewer: scaffold provider + CLI (seed + stub ingest)
## Context

UK workplace pension at planviewer.fidelity.co.uk has no public API; the SPA
calls a private JSON backend at prd.wiciam.fidelity.co.uk/cvmfe/api/*. Viktor
confirmed in DevTools that an OPTIONS preflight lists auth headers
(ch, fid, rid, sid, tbid, theosreferer, ua). Full reverse-engineering of the
endpoint paths is pending Viktor's POST cURL paste for transactions +
holdings views.

Until those endpoints are captured, ship the scaffold: provider module, CLI
commands, tests, docs. This unblocks installing Playwright in the image and
lets Viktor run the one-off seed command on his laptop ahead of the data
integration.

## This change

- broker_sync/providers/fidelity_planviewer.py
  - FidelityCreds namedtuple (storage_state_path, plan_id).
  - FidelitySessionError (401 → re-seed), FidelityProviderConfigError.
  - FidelityPlanViewerProvider: .accounts() returns a single
    WORKPLACE_PENSION account, .fetch() raises until endpoints are wired.
- broker_sync/cli.py
  - fidelity-seed: launches headed Chromium so Viktor can log in and tick
    "Remember device", then dumps storage_state.json.
  - fidelity-ingest: stub matching the invest-engine / trading212 CLI
    shape; reads storage_state + plan_id, pipes through the shared pipeline.
- tests/providers/test_fidelity_planviewer.py
  - Asserts the single-account shape + the loud-failure guard.
- docs/providers/fidelity-planviewer.md
  - Architecture diagram, one-time seed procedure, backfill + monthly
    commands, alert runbook.
- pyproject.toml
  - playwright ^1.47 as a first-class dep (used only by fidelity-seed and
    later by the session-refresh step in fidelity-ingest).

## What is NOT in this change

- Endpoint wiring in provider.fetch() — blocked on DevTools POST cURL.
- Infra CronJob + Vault secret + Prometheus alert — lands once the first
  manual backfill succeeds and we know the Chromium image size is fine.
- Dockerfile Chromium install — same trigger.

## Verification

### Automated

$ poetry run pytest tests/providers/test_fidelity_planviewer.py -v
2 passed in 0.08s

$ poetry run pytest -q
122 passed, 1 skipped in 1.07s

$ poetry run mypy broker_sync/providers/fidelity_planviewer.py broker_sync/cli.py
Success: no issues found in 2 source files

$ poetry run ruff check broker_sync/providers/fidelity_planviewer.py broker_sync/cli.py tests/providers/test_fidelity_planviewer.py
All checks passed!

### Manual (Viktor, later)

1. poetry install && poetry run playwright install chromium
2. poetry run broker-sync fidelity-seed --out /tmp/state.json
3. Chromium opens → log in → tick "Remember device" → press Enter
4. vault kv patch secret/broker-sync fidelity_storage_state=@/tmp/state.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:09:04 +00:00
Viktor Barzin
c830856ba1 imap: route IE BUYs to ISA first-£20k / GIA overflow per UK tax year
## Context

Viktor's InvestEngine account has both an ISA and a GIA wrapper. Trade
confirmation emails (info@investengine.com) are identical between them —
subject "Here's how your portfolio looks now", body shows "Client name:
Viktor Barzin" with no portfolio/account type. That left the IMAP parser
hardcoded to route every IE BUY to the ISA (invest-engine-primary),
which produced a 2339-share over-count when 2023-24 GIA buys landed in
the ISA during the 2026-04-18 reconciliation.

Viktor's rule: from 6 April each tax year, BUYs fill ISA up to the
£20,000 cap, then overflow to GIA. This commit codifies that rule in a
standalone batch splitter and applies it at the ImapProvider boundary.

Also picks up a silent-drop bug surfaced during the same reconciliation:
WF's /import (unlike /import/check) rejects naive datetimes with
"Invalid date". The sink now coerces tzinfo=UTC defensively so every
provider gets the same guarantee.

## This change

- `_split_ie_by_isa_cap(activities)` — sorts all IE-ISA BUYs by date and
  walks them once per UK tax year (6 April boundary). A BUY whose running
  tax-year total BEFORE it is strictly below £20k stays on the ISA;
  otherwise it flips to a new `invest-engine-gia` account_id. No
  fractional splits — boundary activities go whole to whichever bucket
  their pre-running-total dictates. Non-IE and non-BUY activities pass
  through unchanged.
- `ImapProvider.accounts()` gains an `invest-engine-gia` Account so the
  pipeline's `_ensure_accounts` can resolve both.
- `ImapProvider.fetch()` calls the splitter on the full batch before
  applying the `since`/`before` date filter — batch-level sort
  guarantees consistent routing regardless of the order IMAP returns
  messages.
- `WealthfolioSink._activity_to_import_row` coerces naive datetimes to
  UTC so the row passes WF /import validation.

## What is NOT in this change

- No retroactive re-routing of data already in WF. Historical
  finance-mysql rows (all lumped to `invest-engine-primary` or
  `invest-engine-gia` by the existing heuristic) keep their current
  account assignment. If a past tax-year was routed "wrong" under the
  new rule, that's corrected manually via the WF API, not here.
- No change to the Schwab or trading212 paths.

## Verification

### Automated

\`\`\`
$ poetry run pytest tests/providers/test_imap.py -v
tests/providers/test_imap.py::test_uk_tax_year_start_before_april_6_rolls_back PASSED
tests/providers/test_imap.py::test_single_tax_year_under_cap_stays_isa PASSED
tests/providers/test_imap.py::test_overflow_past_cap_flips_to_gia PASSED
tests/providers/test_imap.py::test_tax_year_boundary_resets_cap PASSED
tests/providers/test_imap.py::test_out_of_order_activities_sorted_before_cap_applied PASSED
tests/providers/test_imap.py::test_non_ie_activities_passed_through_unchanged PASSED
6 passed in 0.36s

$ poetry run pytest -q --ignore=tests/test_cli.py
116 passed, 1 skipped in 2.76s

$ poetry run ruff check broker_sync/providers/imap.py broker_sync/sinks/wealthfolio.py
All checks passed!

$ poetry run mypy broker_sync/providers/imap.py broker_sync/sinks/wealthfolio.py
Success: no issues found in 2 source files
\`\`\`

### Manual verification

The tzinfo fix was validated against the live WF instance during the
2026-04-18 reconciliation — before the fix, /import returned
\`"errors": {"symbol": ["Invalid date '2022-05-24T00:00:00'."]}\` for
every IMAP activity; after, the same payload imported cleanly.

The splitter was not exercised against live IMAP data because Viktor's
mailbox only has Apr 2022 → Feb 2024 emails, all inside finance.position's
existing coverage. Running IMAP ingest with \`since=2024-04-06\` yields
fetched=0. The unit tests cover the boundary arithmetic; a live run
will happen when newer emails are parsed (or when finance coverage is
re-scoped).

## Reproduce locally

1. \`poetry install\`
2. \`poetry run pytest tests/providers/test_imap.py\`
3. Expected: 6 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:02:49 +00:00
Viktor Barzin
a190875f63 Add finance_mysql provider + CLI for historical backfill
finance.position (171 rows, 2020-06-07 to 2025-12-19) is the only source
of InvestEngine + Schwab trade history pre-dating the broker-sync project.
This provider reads it once and pushes every row into the correct WF
account (.L tickers → IE ISA, others → Schwab).

Dedup: external_id = 'finance-mysql:position:<PK>' — idempotent on re-run.
Auth: aiomysql as MySQL root (user-authorized) against the standalone
mysql:8.4 in-cluster service.

New CLI: broker-sync finance-mysql-import
New tests: 5 unit tests covering route, symbol normalise, BUY/SELL
detection.

poetry run pytest -q   →  114 passed, 1 skipped
poetry run mypy        →  clean (aiomysql shielded with type: ignore)
poetry run ruff check  →  clean
2026-04-17 22:38:21 +00:00
Viktor Barzin
74b2179c83 sinks: read summary.imported as truth for partial-persist detection
The /import response returns activities=[input echo with errors annotated]
— its length equals input size regardless of actual persistence. The
summary{total,imported,skipped,duplicates} block is the authoritative
signal. When imported<total, raise with errorMessage + skipped + duplicates
counts.
2026-04-17 22:30:24 +00:00
Viktor Barzin
4e2da87637 sinks: detect silent Wealthfolio /import drops
After the check step returns isValid=true + no errors, a row can still
be silently dropped by /import (response returns activities=[] on
200 OK). Root-cause is usually a field that check hydrates but
/import re-normalises differently (date string form, asset_id resolution).

When we send N valid rows and get back 0, raise ImportValidationError
with a snippet of the check output + first warning — gives the
operator a concrete hint to fix the producer instead of silently
growing dedup against activities that never landed.

poetry run pytest -q   →  109 passed, 1 skipped
poetry run mypy        →  clean
poetry run ruff check  →  clean
2026-04-17 22:24:36 +00:00
Viktor Barzin
6efd03570a Add imap-ingest CLI + ImapProvider: route emails to IE/Schwab parsers
Wires the IE + Schwab email parsers into an actual runnable sync. Walks
the IMAP mailbox, routes each message by sender domain:
  - *@investengine.com → invest_engine.parse_invest_engine_email
  - *@schwab.com       → schwab.parse_schwab_email
then pushes the resulting Activities through the shared pipeline.

broker-sync imap-ingest — new CLI command taking IMAP_HOST/USER/PASSWORD/
DIRECTORY (mirrors the old wealthfolio-sync image's env shape so the
Terraform CronJob's existing env wiring works unchanged).

Verified: poetry run pytest -q → 109 passed + 1 skipped; mypy strict
clean (37 files); ruff + yapf clean.
2026-04-17 22:12:05 +00:00
Viktor Barzin
f089b8b93a Add Schwab email parser (port from finance/)
Schwab's workplace-RSU confirmation emails have 5 data td elements
with class='dark-background-body' align='right': date, direction, qty,
ticker, price-with-currency-sign. One email → one Activity.

- parse_schwab_email(raw_html) -> list[Activity] (1-item or empty)
- Empty on any parse failure (IMAP batch shouldn't crash on one bad mail)
- Deterministic external_id ('schwab📅ticker:type:qty') — stable
  across re-pulls so dedup works
- Hardcoded to account 'schwab-workplace' / AccountType.GIA / USD
- 6 unit tests: SELL + BUY happy path, malformed, missing cells,
  external-id stability, commas in price

Dropped from the original finance port:
- msg_timestamp-based external id (non-deterministic — would re-import
  on every IMAP walk). Replaced with a hash-stable key.
- Currency.from_sign() currency hack. Schwab US is USD-only; we'll add
  FX when that changes.

poetry run pytest -q   →  109 passed, 1 skipped
poetry run mypy        →  clean (added types-python-dateutil)
poetry run ruff check  →  clean
2026-04-17 22:08:40 +00:00
Viktor Barzin
1aa60ce348 Merge ie-email-parser: HTML + CSV fallbacks + failure-mode tests
# Conflicts:
#	broker_sync/providers/parsers/invest_engine.py
#	tests/providers/parsers/test_invest_engine.py
2026-04-17 22:06:29 +00:00
Viktor Barzin
89e9710d24 Merge ie-bearer-client: IE Bearer-token HTTP client + CLI subcommand 2026-04-17 22:05:11 +00:00
Viktor Barzin
87526898e6 Pin InvestEngine parser failure modes — empty-on-junk + partial-match
Context: The port's graceful-failure contract was implicit in the way
each strategy returns None/[] on malformed input, but without tests it
was an accidental property that could regress silently. Codify it.

Two invariants, each backed by a fixture:

1. Junk email → empty list, never raise.
   `unparseable.eml` is a pure-marketing IE newsletter with no order
   data. All three strategies try and fail; parse_invest_engine_email
   returns []. No exception leaks.

2. Partial HTML email → intact orders only.
   `html_partial_match.eml` has two nested summary tables: one with a
   valid VUAG order, one that is missing both the ticker and "Bought N
   @ £P" rows (simulates IE dropping content mid-render). The parser
   returns just the VUAG order.

No implementation change needed — the behaviour existed as a side
effect of _try_html_summary_table returning None on missing fields.
These tests lock it down so future refactors can't quietly break it.

Test plan:
  poetry run pytest tests/providers/parsers/ -q   →  8 passed in 0.19s
  poetry run mypy broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py   →  clean
  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   →  clean (no diff)

Manual verification:
- Load unparseable.eml → parse returns [].
- Load html_partial_match.eml → parse returns exactly 1 activity (VUAG).
2026-04-17 22:02:48 +00:00
Viktor Barzin
020ba16723 Add CSV attachment fallback for InvestEngine email parser
Context: IE has not (yet) sent CSV-attached statements in production,
but the upstream parser had _extract_positions_csv as a third fallback
for exactly this case. Keeping the fallback preserves behaviour-parity
with the legacy parser and makes future statement support one fixture
away — the shape is documented by column set, not scraped live.

Unlike the upstream which split the body on whitespace and broke on any
embedded commas in names, this port walks real MIME attachments using
Python's csv.DictReader. A part qualifies as CSV if:
- its Content-Type is text/csv / application/csv / application/vnd.ms-excel, OR
- its filename ends in .csv (defence against IE mis-labelling the part)

Rows missing required columns or containing unparseable numbers/dates
are skipped silently — consistent with the "partial match" contract:
a half-corrupt CSV yields whatever rows were intact. Required columns:
ticker, unit_price, quantity, date (YYYY-MM-DD), currency. Non-GBP
rows are filtered because the IE ISA is strictly sterling — flagging
this assumption in the review notes.

This change:
- Adds `_parse_csv_attachment(raw_email)` as the third strategy after
  text/plain and text/html; it re-parses the raw email bytes so we can
  inspect Content-Type/filename on each part.
- Flags symbols/currencies, filters non-GBP, and runs each row through
  the shared `_build_activity` so external_id formation matches every
  other strategy (dedup stays consistent across strategies).
- Fixture `csv_attachment.eml` has three rows (VUAG, SWDA, VUSA) in a
  `text/csv` part with a `.csv` filename — covers both detection paths.

Test plan:
  poetry run pytest tests/providers/parsers/ -q   →  6 passed in 0.15s
  poetry run mypy broker_sync/providers/parsers/invest_engine.py tests/providers/parsers/test_invest_engine.py  →  clean
  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  →  clean (no diff)

Manual verification: load csv_attachment.eml, call parse_invest_engine_email,
assert 3 activities each with symbol in {VUAG,SWDA,VUSA}, currency=GBP,
notes containing "csv".
2026-04-17 22:01:46 +00:00
Viktor Barzin
72d348e294 Add HTML table fallback for InvestEngine email parser
Context: Plain-text IE emails vanished around 2024-Q2 when IE switched to
an HTML-only template with per-order nested summary tables. The RFC 2822
line parser returns [] on those modern emails, so we need a fallback
that walks the HTML table structure.

Upstream _extract_from_html parsed a fixed DOM path (table[1].tr[10].
table) and only handled ONE order per email. The real IE HTML template
nests one summary <table> per ticker inside the second top-level table —
multiple orders in a single batched confirmation are common — so this
port walks every leaf table (no child <table>) and interprets each one
as an independent trade summary. Structural (non-leaf) tables are
skipped to avoid double-counting via get_text().

This change:
- `_parse_html_tables(body)` extracts the date once from the full text
  then walks leaf tables looking for "Bought N @ £P" rows.
- `_try_html_summary_table` parses one leaf; returns None on structural
  tables or missing ticker/qty/price — so a partial email yields only
  its intact orders (the "2 orders, 1 parseable → 1 returned" invariant
  works by construction without raising).
- `parse_invest_engine_email` now falls through text/plain → text/html
  in the multipart message, picking the first strategy that returns
  activities. Order matters: text/plain wins when both succeed because
  the RFC 2822 strategy is the more constrained grammar.
- Regexes are module-level constants so they compile once per process.

Fixture `html_two_orders.eml` is a minimal-but-realistic multipart email
with two nested summary tables (VUAG + SWDA), no personal data beyond
tickers/qty/price.

Test plan:
  poetry run pytest tests/providers/parsers/ -q
  → 5 passed in 0.16s
  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 html_two_orders.eml, call parse_invest_engine_email,
assert len == 2 with both expected tickers (VUAG, SWDA) and numbers,
dates set to 2026-04-01.
2026-04-17 21:58:15 +00:00
Viktor Barzin
9ec8ece2d9 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:55:01 +00:00
37 changed files with 8043 additions and 82 deletions

103
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,103 @@
name: Build and Push
# Off-infra build (ADR-0002). Canonical repo is Forgejo viktor/broker-sync, which
# push-mirrors here; this workflow builds on GitHub-hosted runners, pushes the
# image to GHCR, then signals the Woodpecker deploy pipeline (repo 0)
# to roll the cluster — the homelab never sees build IO or registry pushes.
#
# Committed on the FORGEJO side (the mirror is one-way; commits made on GitHub
# are overwritten by the next sync). Generated by infra/scripts/offinfra-onboard.
on:
push:
branches: [master]
workflow_dispatch: {}
permissions:
contents: read
packages: write
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Lint + type-check + test
run: |
pip install --no-cache-dir "poetry==1.8.4"
poetry install --no-interaction --no-root
poetry run ruff check .
poetry run mypy broker_sync tests
poetry run pytest -q
build:
needs: lint-and-test
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.sha }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history + tags so svu sees the last vX.Y.Z
fetch-tags: true
# Auto-semver (svu): tag-only, pushed to CANONICAL Forgejo (GitHub tags
# would be wiped by the next mirror sync). Best-effort: never blocks the build.
- name: Compute + tag semver (svu)
env:
FORGEJO_GIT_TOKEN: ${{ secrets.FORGEJO_GIT_TOKEN }}
run: |
set +e
git config user.email "ci@viktorbarzin.me"
git config user.name "broker-sync-ci"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
curl -sSL https://github.com/caarlos0/svu/releases/download/v3.4.1/svu_3.4.1_linux_amd64.tar.gz | tar -xz svu
CUR=$(./svu current 2>/dev/null)
NEXT=$(./svu next 2>/dev/null)
echo "svu current=[$CUR] next=[$NEXT]"
if [ -n "$NEXT" ] && [ "$NEXT" != "$CUR" ]; then
git tag "$NEXT" 2>/dev/null
git push "https://viktor:${FORGEJO_GIT_TOKEN}@forgejo.viktorbarzin.me/viktor/broker-sync.git" "$NEXT" && echo "pushed tag $NEXT to forgejo" || echo "tag push failed (non-blocking)"
fi
exit 0
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64
# Single-manifest images (no provenance/SBOM attestation children) so
# registry retention can never orphan index children (ADR-0002).
provenance: false
tags: |
ghcr.io/viktorbarzin/wealthfolio-sync:${{ steps.meta.outputs.sha }}
ghcr.io/viktorbarzin/wealthfolio-sync:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Keep the newest ~10 versions on ghcr (latest rides the newest one).
- name: ghcr retention (keep 10)
uses: actions/delete-package-versions@v5
continue-on-error: true
with:
package-name: wealthfolio-sync
package-type: container
min-versions-to-keep: 10
notify-failure:
needs: [lint-and-test, build]
if: failure()
runs-on: ubuntu-latest
steps:
- name: Slack notify
run: |
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\":rotating_light: broker-sync off-infra build FAILED: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
"${{ secrets.SLACK_WEBHOOK }}" || true

View file

@ -1,5 +1,9 @@
when: when:
- event: [manual, push] # Manual-only — fired with IMAGE_TAG by the build pipeline (or
# by a human kicking off a deploy from the Woodpecker UI).
# The earlier `[manual, push]` would fire on every push and fail
# at check-vars because IMAGE_TAG is unset on push events.
- event: manual
steps: steps:
- name: check-vars - name: check-vars

View file

@ -20,14 +20,56 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Playwright needs a big list of system libs for Chromium (fonts, NSS, libs
# for rendering, audio stubs, etc.). Mirror the list Playwright publishes at
# https://playwright.dev/docs/browsers#system-requirements for Debian 12.
# Fidelity PlanViewer is the only consumer today; gated to the fidelity-*
# CronJobs via the provider's explicit Playwright import.
RUN apt-get update && apt-get install --no-install-recommends -y \
ca-certificates \
fonts-liberation \
fonts-noto-color-emoji \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libgbm1 \
libglib2.0-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xvfb \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin broker && \ RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin broker && \
mkdir -p /data && chown -R broker:broker /data mkdir -p /data && chown -R broker:broker /data
COPY --from=builder --chown=broker:broker /app /app COPY --from=builder --chown=broker:broker /app /app
# Install Chromium into broker's cache so Playwright (running as broker)
# can pick it up. `PLAYWRIGHT_BROWSERS_PATH=0` forces a co-located install
# next to the python package — the simpler path on slim images.
ENV PATH="/app/.venv/bin:${PATH}" \ ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1 \
PLAYWRIGHT_BROWSERS_PATH=/app/.playwright-browsers
RUN mkdir -p "${PLAYWRIGHT_BROWSERS_PATH}" && \
chown -R broker:broker "${PLAYWRIGHT_BROWSERS_PATH}"
USER broker USER broker
RUN playwright install chromium
ENTRYPOINT ["broker-sync"] ENTRYPOINT ["broker-sync"]
CMD ["version"] CMD ["version"]

View file

@ -230,6 +230,382 @@ def invest_engine(
asyncio.run(_run()) asyncio.run(_run())
@app.command("ibkr")
def ibkr(
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
wf_session_path: str = typer.Option(
"/data/wealthfolio_session.json", envvar="WF_SESSION_PATH"
),
ibkr_flex_token: str = typer.Option(..., envvar="IBKR_FLEX_TOKEN"),
ibkr_flex_query_id: str = typer.Option(..., envvar="IBKR_FLEX_QUERY_ID"),
ibkr_account_id_upstream: str = typer.Option(..., envvar="IBKR_ACCOUNT_ID_UPSTREAM"),
pushgateway_url: str = typer.Option(
"http://prometheus-prometheus-pushgateway.monitoring:9091/metrics",
envvar="PUSHGATEWAY_URL",
),
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
) -> None:
"""Phase 2c — daily IBKR Flex Web Service → Wealthfolio sync.
Pulls an Activity Flex Query (Trades + Cash + OpenPositions), maps to
broker-sync Activities, pushes through the shared pipeline, then
reconciles broker-reported OpenPositions against WF-computed quantities
and publishes a Pushgateway drift metric.
The Wealthfolio account UUID is resolved via the pipeline's
ensure_account(provider="ibkr", providerAccountId=IBKR_ACCOUNT_ID_UPSTREAM)
lookup no need to wire the UUID in as a separate env var.
"""
import time
from decimal import Decimal
from broker_sync.dedup import SyncRecordStore
from broker_sync.metrics import push_pushgateway
from broker_sync.pipeline import sync_provider_to_wealthfolio
from broker_sync.providers.ibkr import IBKRAccountMismatchError, IBKRProvider
from broker_sync.sinks.wealthfolio import WealthfolioSink
_setup_logging()
data = Path(data_dir)
data.mkdir(parents=True, exist_ok=True)
async def _run() -> None:
sink = WealthfolioSink(
base_url=wf_base_url,
username=wf_username,
password=wf_password,
session_path=wf_session_path,
)
provider = IBKRProvider(
token=ibkr_flex_token,
query_id=ibkr_flex_query_id,
upstream_account_id=ibkr_account_id_upstream,
)
dedup = SyncRecordStore(data / "sync.db")
try:
if not Path(wf_session_path).exists():
await sink.login()
result = await sync_provider_to_wealthfolio(
provider=provider,
sink=sink,
dedup=dedup,
)
# Resolve WF UUID for reconciliation. ensure_account is idempotent
# and already ran inside sync_provider_to_wealthfolio; this is a
# cheap re-lookup that returns the same UUID.
wf_uuid = await sink.ensure_account(provider.accounts()[0])
# Reconciliation: broker truth vs WF truth.
wf_qty = await sink.compute_position_qty(wf_uuid)
drift_metrics: list[tuple[str, dict[str, str], float]] = []
for symbol, broker_qty in provider.open_positions():
drift = broker_qty - wf_qty.get(symbol, Decimal(0))
drift_metrics.append(
(
"ibkr_position_drift_shares",
{"symbol": symbol, "account": "ibkr-uk"},
float(drift),
)
)
# Cash balances (one row per currency from CashReport, plus a
# BASE_SUMMARY row consolidated in account base currency).
for currency, ending_cash in provider.cash_balances():
drift_metrics.append(
(
"ibkr_cash_balance",
{"currency": currency, "account": "ibkr-uk"},
float(ending_cash),
)
)
drift_metrics.append(
("ibkr_sync_last_success_timestamp_seconds", {}, float(time.time()))
)
await push_pushgateway("broker-sync-ibkr", drift_metrics, pushgateway_url)
except IBKRAccountMismatchError as e:
typer.echo(f"IBKR: {e}", err=True)
sys.exit(2)
finally:
await provider.close()
await sink.close()
typer.echo(
f"ibkr: fetched={result.fetched} new={result.new_after_dedup} "
f"imported={result.imported} failed={result.failed}"
)
if result.failed > 0:
sys.exit(1)
asyncio.run(_run())
@app.command("finance-mysql-import")
def finance_mysql_import(
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
wf_session_path: str = typer.Option("/data/wealthfolio_session.json",
envvar="WF_SESSION_PATH"),
db_host: str = typer.Option(..., envvar="FINANCE_DB_HOST"),
db_port: int = typer.Option(3306, envvar="FINANCE_DB_PORT"),
db_user: str = typer.Option(..., envvar="FINANCE_DB_USER"),
db_password: str = typer.Option(..., envvar="FINANCE_DB_PASSWORD"),
db_name: str = typer.Option("finance", envvar="FINANCE_DB_NAME"),
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
) -> None:
"""One-shot backfill: read the retired finance app's MySQL position table
and push every row into the correct Wealthfolio account (IE for .L
tickers, Schwab for US tickers). Idempotent via dedup."""
from broker_sync.dedup import SyncRecordStore
from broker_sync.pipeline import sync_provider_to_wealthfolio
from broker_sync.providers.finance_mysql import (
FinanceMySQLCreds,
FinanceMySQLProvider,
)
from broker_sync.sinks.wealthfolio import WealthfolioSink
_setup_logging()
data = Path(data_dir)
data.mkdir(parents=True, exist_ok=True)
async def _run() -> None:
sink = WealthfolioSink(
base_url=wf_base_url,
username=wf_username,
password=wf_password,
session_path=wf_session_path,
)
provider = FinanceMySQLProvider(
FinanceMySQLCreds(
host=db_host,
port=db_port,
user=db_user,
password=db_password,
database=db_name,
))
dedup = SyncRecordStore(data / "sync.db")
try:
if not Path(wf_session_path).exists():
await sink.login()
result = await sync_provider_to_wealthfolio(
provider=provider,
sink=sink,
dedup=dedup,
)
finally:
await sink.close()
typer.echo(f"finance-mysql: fetched={result.fetched} "
f"new={result.new_after_dedup} "
f"imported={result.imported} "
f"failed={result.failed}")
if result.failed > 0:
sys.exit(1)
asyncio.run(_run())
@app.command("imap-ingest")
def imap_ingest(
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
wf_session_path: str = typer.Option("/data/wealthfolio_session.json",
envvar="WF_SESSION_PATH"),
imap_host: str = typer.Option(..., envvar="IMAP_HOST"),
imap_user: str = typer.Option(..., envvar="IMAP_USER"),
imap_password: str = typer.Option(..., envvar="IMAP_PASSWORD"),
imap_directory: str = typer.Option("INBOX", envvar="IMAP_DIRECTORY"),
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
) -> None:
"""Phase 2/3 — ingest InvestEngine + Schwab confirmation emails via IMAP.
Walks the mailbox, routes each message by `From:` sender domain to the
matching parser, pushes any resulting activities through the shared
pipeline (dedup Wealthfolio CSV-free JSON import).
"""
from broker_sync.dedup import SyncRecordStore
from broker_sync.pipeline import sync_provider_to_wealthfolio
from broker_sync.providers.imap import ImapCreds, ImapProvider
from broker_sync.sinks.wealthfolio import WealthfolioSink
_setup_logging()
data = Path(data_dir)
data.mkdir(parents=True, exist_ok=True)
async def _run() -> None:
sink = WealthfolioSink(
base_url=wf_base_url,
username=wf_username,
password=wf_password,
session_path=wf_session_path,
)
provider = ImapProvider(
ImapCreds(
host=imap_host,
user=imap_user,
password=imap_password,
directory=imap_directory,
))
dedup = SyncRecordStore(data / "sync.db")
try:
if not Path(wf_session_path).exists():
await sink.login()
result = await sync_provider_to_wealthfolio(
provider=provider,
sink=sink,
dedup=dedup,
)
finally:
await sink.close()
typer.echo(f"imap-ingest: fetched={result.fetched} "
f"new={result.new_after_dedup} "
f"imported={result.imported} "
f"failed={result.failed}")
if result.failed > 0:
sys.exit(1)
asyncio.run(_run())
@app.command("fidelity-seed")
def fidelity_seed(
out: str = typer.Option(
"fidelity_storage_state.json",
help="Where to write the storage_state JSON (stage it to Vault afterwards)",
),
url: str = typer.Option(
"https://pv.planviewer.fidelity.co.uk/",
help="PlanViewer SPA URL — defaults to the production UK landing",
),
) -> None:
"""One-off: launch a headed Chromium so Viktor can log into PlanViewer and
capture a long-lived storage_state (cookies + localStorage) for the monthly
cron.
Expected flow:
1. Chromium opens on the PlanViewer login page.
2. Viktor enters username, password, memorable word, MFA code.
3. Viktor ticks "Remember device" / "Trust this browser" if offered.
4. Viktor waits until the dashboard loads, then presses Enter in the terminal.
5. Script dumps storage_state.json and exits.
6. Viktor runs ``vault kv patch secret/broker-sync fidelity_storage_state=@...``.
"""
_setup_logging()
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
typer.echo(
"Playwright is not installed — run `poetry install` first.", err=True)
raise typer.Exit(code=2) from e
typer.echo(f"Opening {url} in a headed browser — log in, tick "
"'Remember device' if offered, then press Enter here.")
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(url)
input("Press Enter once you're fully logged in and the dashboard is visible… ")
context.storage_state(path=out)
browser.close()
typer.echo(f"Wrote {out} — stage it to Vault:")
typer.echo(f" vault kv patch secret/broker-sync fidelity_storage_state=@{out}")
@app.command("fidelity-ingest")
def fidelity_ingest(
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
wf_session_path: str = typer.Option("/data/wealthfolio_session.json", envvar="WF_SESSION_PATH"),
storage_state_path: str = typer.Option(
...,
envvar="FIDELITY_STORAGE_STATE_PATH",
help="Path on disk to storage_state.json (materialised from Vault by the init container)",
),
plan_id: str = typer.Option(..., envvar="FIDELITY_PLAN_ID"),
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
mode: str = typer.Option("steady", help="steady = last-60-days; backfill = full history"),
) -> None:
"""Sync Fidelity UK PlanViewer contributions + fund purchases into Wealthfolio."""
from broker_sync.dedup import SyncRecordStore
from broker_sync.pipeline import sync_provider_to_wealthfolio
from broker_sync.providers.fidelity_planviewer import (
FidelityCreds,
FidelityPlanViewerProvider,
)
from broker_sync.sinks.wealthfolio import WealthfolioSink
_setup_logging()
if mode == "steady":
since: datetime | None = datetime.now(UTC) - timedelta(days=60)
elif mode == "backfill":
since = None
else:
typer.echo(f"Unknown mode: {mode!r}. Use 'steady' or 'backfill'.", err=True)
sys.exit(2)
async def _run() -> None:
from broker_sync.providers.fidelity_planviewer import (
gains_offset_delta_activity,
)
sink = WealthfolioSink(
base_url=wf_base_url,
username=wf_username,
password=wf_password,
session_path=wf_session_path,
)
provider = FidelityPlanViewerProvider(FidelityCreds(
storage_state_path=storage_state_path,
plan_id=plan_id,
))
dedup = SyncRecordStore(Path(data_dir) / "sync.db")
try:
if not Path(wf_session_path).exists():
await sink.login()
result = await sync_provider_to_wealthfolio(
provider=provider, sink=sink, dedup=dedup, since=since,
)
# PlanViewer doesn't expose per-fund unit prices in any feed
# WF can consume, so the only way to keep WF's pension total in
# line with the live PlanViewer pot value is to emit a small
# DEPOSIT (or WITHDRAWAL on a market drop) each run sized to
# the growth since the last scrape. The dav_corrected PG view
# subtracts these offsets from net_contribution so the
# dashboard's Growth/ROI panels stay accurate.
gains_delta_emitted = 0
if provider.last_holdings:
wf_account_id = await sink.ensure_account(provider.accounts()[0])
prior_offset = await sink.cumulative_amount_with_notes_prefix(
account_id=wf_account_id,
notes_prefix="fidelity-planviewer:unrealised-gains-offset",
)
delta = gains_offset_delta_activity(
holdings=provider.last_holdings,
total_real_contribution=provider.last_total_contribution,
prior_offset_cumulative=prior_offset,
as_of=datetime.now(UTC),
)
if delta is not None:
await sink.import_activities([delta])
gains_delta_emitted = 1
finally:
await sink.close()
typer.echo(f"fidelity-ingest: fetched={result.fetched} "
f"new={result.new_after_dedup} "
f"imported={result.imported} "
f"failed={result.failed} "
f"gains_delta={gains_delta_emitted}")
if result.failed > 0:
sys.exit(1)
asyncio.run(_run())
def _setup_logging() -> None: def _setup_logging() -> None:
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,

51
broker_sync/metrics.py Normal file
View file

@ -0,0 +1,51 @@
"""Pushgateway client for broker-sync providers.
One function: push a list of (metric, labels, value) tuples to Prometheus
Pushgateway under a given job name. Used by providers to surface per-run
drift / staleness / row counts that Prometheus can alert on.
In-cluster URL: http://prometheus-prometheus-pushgateway.monitoring:9091/metrics
Pass via the ``pushgateway_url`` argument or the ``PUSHGATEWAY_URL`` env var.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Iterable
import httpx
log = logging.getLogger(__name__)
def _format_metric(name: str, labels: dict[str, str], value: float) -> str:
if labels:
body = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
return f"{name}{{{body}}} {value}\n"
return f"{name} {value}\n"
async def push_pushgateway(
job: str,
metrics: Iterable[tuple[str, dict[str, str], float]],
pushgateway_url: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
"""POST text-format metrics to Pushgateway under ``job``.
``pushgateway_url`` falls back to the env var ``PUSHGATEWAY_URL``.
Raises ``RuntimeError`` if the URL is unset or POST returns non-2xx.
"""
url = pushgateway_url or os.environ.get("PUSHGATEWAY_URL")
if not url:
raise RuntimeError("PUSHGATEWAY_URL not set and no override provided")
body = "".join(_format_metric(n, lbls, v) for n, lbls, v in metrics)
target = f"{url.rstrip('/')}/job/{job}"
async with httpx.AsyncClient(transport=transport, timeout=15.0) as c:
resp = await c.post(target, content=body, headers={"Content-Type": "text/plain"})
if resp.status_code >= 300:
raise RuntimeError(
f"pushgateway POST {target} returned HTTP {resp.status_code}: "
f"{resp.text[:200]}"
)
log.info("pushgateway: pushed %d metrics to job=%s", len(body.splitlines()), job)

View file

@ -102,3 +102,27 @@ def _fmt(v: Decimal | None) -> str:
if v is None: if v is None:
return "" return ""
return format(v, "f") return format(v, "f")
@dataclass
class VestEvent:
"""Schwab RSU vest event — written to payslip_ingest.rsu_vest_events.
Carries both the gross vest (shares x FMV) and the sell-to-cover portion
(shares withheld for tax x FMV). Sibling Activity records (one BUY for
the full vest, one SELL for the sold-to-cover slice) are produced
separately for Wealthfolio.
USD-only at parse time; FX conversion happens at the postgres sink via
the ECB daily rate so the DB row carries both the raw USD figures and
the GBP-translated values for dashboard joins.
"""
external_id: str # schwab:{date}:{ticker}:VEST:{shares_vested}
vest_date: datetime
ticker: str
shares_vested: Decimal
shares_sold_to_cover: Decimal | None
fmv_at_vest_usd: Decimal
tax_withheld_usd: Decimal | None
source: str = "schwab_email"
raw: dict[str, str] = field(default_factory=dict)

View file

@ -5,9 +5,10 @@ import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from decimal import Decimal
from broker_sync.dedup import SyncRecordStore from broker_sync.dedup import SyncRecordStore
from broker_sync.models import Account, Activity from broker_sync.models import Account, Activity, ActivityType
from broker_sync.providers.base import Provider from broker_sync.providers.base import Provider
from broker_sync.sinks.wealthfolio import WealthfolioSink from broker_sync.sinks.wealthfolio import WealthfolioSink
@ -51,16 +52,21 @@ async def sync_provider_to_wealthfolio(
async for activity in provider.fetch(since=since, before=before): async for activity in provider.fetch(since=since, before=before):
fetched += 1 fetched += 1
if dedup.has_seen(provider.name, activity.account_id, activity.external_id): # Expand each BUY/SELL into (original, matching DEPOSIT/WITHDRAWAL).
# See `_matched_cash_flow` — without the match, WF's historical Net
# Worth chart shows phantom spikes because BUYs consume cash that
# was never "deposited" according to the activity log.
for act in _with_cash_flow_match(activity):
if dedup.has_seen(provider.name, act.account_id, act.external_id):
continue continue
new_after_dedup += 1 new_after_dedup += 1
_tag_notes(activity, provider.name) _tag_notes(act, provider.name)
original_account_id = activity.account_id original_account_id = act.account_id
# Submit under Wealthfolio's UUID; keep dedup keyed on our id. # Submit under Wealthfolio's UUID; keep dedup keyed on our id.
wf_id = wf_account_ids.get(original_account_id) wf_id = wf_account_ids.get(original_account_id)
if wf_id: if wf_id:
activity.account_id = wf_id act.account_id = wf_id
batch.append((original_account_id, activity)) batch.append((original_account_id, act))
if len(batch) >= _BATCH_SIZE: if len(batch) >= _BATCH_SIZE:
ok, bad = await _flush_batch(sink, dedup, provider.name, batch) ok, bad = await _flush_batch(sink, dedup, provider.name, batch)
imported += ok imported += ok
@ -89,9 +95,7 @@ async def sync_provider_to_wealthfolio(
) )
async def _ensure_accounts( async def _ensure_accounts(sink: WealthfolioSink, accounts: list[Account]) -> dict[str, str]:
sink: WealthfolioSink, accounts: list[Account]
) -> dict[str, str]:
"""Return {our_account_id: wealthfolio_uuid}.""" """Return {our_account_id: wealthfolio_uuid}."""
out: dict[str, str] = {} out: dict[str, str] = {}
for account in accounts: for account in accounts:
@ -134,7 +138,9 @@ async def _flush_batch(
for original_account_id, a in batch: for original_account_id, a in batch:
wf_id = by_external.get(a.external_id) wf_id = by_external.get(a.external_id)
dedup.record( dedup.record(
provider_name, original_account_id, a.external_id, provider_name,
original_account_id,
a.external_id,
wealthfolio_activity_id=wf_id, wealthfolio_activity_id=wf_id,
) )
ok += 1 ok += 1
@ -144,3 +150,56 @@ async def _flush_batch(
async def collect(iterator: AsyncIterator[Activity]) -> list[Activity]: async def collect(iterator: AsyncIterator[Activity]) -> list[Activity]:
"""Tiny helper — drain an async iterator to a list. Mainly for tests.""" """Tiny helper — drain an async iterator to a list. Mainly for tests."""
return [a async for a in iterator] return [a async for a in iterator]
# -- Cash-flow matching --------------------------------------------------
# BUY and SELL activities touch shares, not cash. Without an explicit
# DEPOSIT/WITHDRAWAL on the same day, WF models the account as having
# "phantom" cash debt — and its Net Worth chart shows cliff-jumps
# whenever a lump offset is applied after the fact.
#
# The pipeline emits a matching DEPOSIT (for BUY) or WITHDRAWAL (for SELL)
# right alongside each trade so the account's cash balance reconciles to
# ~0 at every point in time. Providers that already emit real cash flows
# (e.g. a Trading212 "deposit" endpoint, if we ever wire it) should set
# `Provider.emits_matching_cash_flow = True` to opt out — no provider
# does today (Trading212 only exposes BUY/SELL via the /orders endpoint).
def _matched_cash_flow(a: Activity) -> Activity | None:
"""Return the DEPOSIT/WITHDRAWAL that funds/receives the BUY/SELL `a`.
Returns None for every other activity type those already touch cash
directly (DEPOSIT, WITHDRAWAL, DIVIDEND, FEE, TAX, TRANSFER_*,
CONVERSION_*).
"""
if a.activity_type is ActivityType.BUY:
if a.quantity is None or a.unit_price is None:
return None
amount = a.quantity * a.unit_price + (a.fee or Decimal(0))
kind, tag = ActivityType.DEPOSIT, "buy"
elif a.activity_type is ActivityType.SELL:
if a.quantity is None or a.unit_price is None:
return None
amount = a.quantity * a.unit_price - (a.fee or Decimal(0))
kind, tag = ActivityType.WITHDRAWAL, "sell"
else:
return None
if amount <= 0:
return None
return Activity(
external_id=f"cash-flow-match:{tag}:{a.external_id}",
account_id=a.account_id,
account_type=a.account_type,
date=a.date,
activity_type=kind,
currency=a.currency,
amount=amount,
notes=f"cash-flow-match:{tag}:{a.external_id}",
)
def _with_cash_flow_match(a: Activity) -> list[Activity]:
"""Expand one activity into [original] or [original, matching cash flow]."""
match = _matched_cash_flow(a)
return [a] if match is None else [a, match]

View file

@ -0,0 +1,331 @@
"""Fidelity UK PlanViewer provider — workplace pension backfill + monthly sync.
PlanViewer has no public individual-member API. The SPA (at
``pv.planviewer.fidelity.co.uk``) and the legacy HTML app (at
``www.planviewer.fidelity.co.uk``) share session cookies via PingFederate
OAuth at ``id.fidelity.co.uk``.
We keep a Playwright-maintained session via ``storage_state.json``:
1. **One-off seed** (``broker-sync fidelity-seed``): Viktor runs a headed
Chromium, logs in (password + memorable word + SMS MFA), clicks
"Remember device". The storage_state is persisted to Vault.
2. **Monthly cron**: loads storage_state, boots headless Chromium, navigates
to the transaction-history page with a wide date range, parses the HTML
table, and intercepts the ``DisplayValuation`` XHR for the current
fund holdings. On 401/idle-timeout we raise
:class:`FidelitySessionError` so Prometheus alerts Viktor to re-seed.
## Emitted Activity / snapshot shape
- One ``DEPOSIT`` per cash-impacting transaction (Regular Premium, Single
Premium, rebate, etc.). ``external_id = fidelity:tx:<sha256[:16]>``.
- Bulk Switches / Fund Switches are skipped (no cash movement).
- After the activity stream drains, the ``fidelity-ingest`` CLI calls
``WealthfolioSink.push_manual_snapshots`` with one ``ManualSnapshotPayload``
per fund holding (today's date, units + cost basis allocated
proportionally to fund value share). This sets per-fund quantity and
cost basis in WF so the dashboard Positions table shows the pension
funds alongside the brokerage assets.
- The old synthetic ``fidelity:gains:<date>`` DEPOSIT is no longer
emitted the snapshot supersedes it. Old offset rows that landed
before this change are corrected at the dashboard layer by the
``dav_corrected`` PG view (``infra/stacks/wealthfolio/main.tf``).
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import AsyncIterator
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, NamedTuple
from broker_sync.models import Account, AccountType, Activity, ActivityType
from broker_sync.providers.parsers.fidelity import (
FidelityCashTx,
FidelityHolding,
parse_transactions_html,
parse_valuation_json,
)
from broker_sync.sinks.wealthfolio import ManualSnapshotPayload, SnapshotPosition
log = logging.getLogger(__name__)
ACCOUNT_ID = "fidelity-workplace-pension"
_CCY = "GBP"
_PV_BASE = "https://www.planviewer.fidelity.co.uk"
_PV_TX_PATH = "/planviewer/DisplayMyPlanMemberTransHist.action"
_PV_VALUATION_PATH = "/planviewer/DisplayValuation.action"
_PV_LANDING = "https://www.planviewer.fidelity.co.uk/"
# A wide backfill cap; scheme can't predate 1990.
_BACKFILL_START = "01 Jan 1990"
class FidelityCreds(NamedTuple):
"""Paths needed to run the provider."""
storage_state_path: str
plan_id: str
headless: bool = True
class FidelitySessionError(Exception):
"""Raised when PlanViewer rejects the saved session — re-seed required."""
class FidelityProviderConfigError(Exception):
"""Raised when provider config is missing or obviously wrong."""
def _tx_to_activity(tx: FidelityCashTx) -> Activity:
"""Map a Fidelity cash transaction to a canonical DEPOSIT."""
return Activity(
external_id=tx.external_id,
account_id=ACCOUNT_ID,
account_type=AccountType.WORKPLACE_PENSION,
date=tx.date,
activity_type=ActivityType.DEPOSIT,
currency=_CCY,
amount=tx.amount,
notes=f"fidelity-planviewer:{tx.tx_type}",
)
class FidelityPlanViewerProvider:
"""Read-only provider against Fidelity UK PlanViewer.
Lifecycle:
- ``accounts()`` advertises the single WF workplace-pension account.
- ``fetch(since, before)`` opens a Playwright session with the saved
storage_state, navigates to the transaction-history page with a wide
date range, scrapes the table, and intercepts the valuation XHR.
- After ``fetch()`` completes, ``last_holdings`` holds the per-fund
unit positions and ``last_total_contribution`` the cumulative cash
contribution used by the ``fidelity-ingest`` CLI to emit a
delta-shaped DEPOSIT that nudges WF's net worth to match the
PlanViewer reported pot value (see ``gains_offset_delta_activity``).
"""
name = "fidelity-planviewer"
def __init__(self, creds: FidelityCreds) -> None:
self._creds = creds
self.last_holdings: list[FidelityHolding] = []
self.last_total_contribution: Decimal = Decimal(0)
def accounts(self) -> list[Account]:
return [
Account(
id=ACCOUNT_ID,
name="Fidelity UK Pension",
account_type=AccountType.WORKPLACE_PENSION,
currency=_CCY,
provider=self.name,
),
]
async def fetch(
self,
*,
since: datetime | None = None,
before: datetime | None = None,
) -> AsyncIterator[Activity]:
state_path = self._creds.storage_state_path
if not Path(state_path).exists():
raise FidelityProviderConfigError(
f"storage_state not found at {state_path}"
"run `broker-sync fidelity-seed` first")
tx_html, valuation_json = await _scrape_live_session(
state_path=state_path, headless=self._creds.headless,
)
transactions = parse_transactions_html(tx_html)
holdings = parse_valuation_json(valuation_json)
log.info("fidelity: parsed %d transactions, %d holdings",
len(transactions), len(holdings))
# Snapshot the per-fund holdings for the CLI to push as a manual
# holdings_snapshot after this generator drains. Wealthfolio's
# activity model can't represent pension fund unit purchases (no
# per-purchase price feed from PlanViewer), so we record current
# state via /api/v1/snapshots/import instead.
self.last_holdings = holdings
self.last_total_contribution = sum(
(t.amount for t in transactions), Decimal(0)
)
for tx in transactions:
if since is not None and tx.date < since:
continue
if before is not None and tx.date >= before:
continue
yield _tx_to_activity(tx)
# Gains-offset DEPOSITs are emitted by the CLI (which has the
# prior cumulative offset from WF). See `gains_offset_delta_activity`.
def gains_offset_delta_activity(
holdings: list[FidelityHolding],
total_real_contribution: Decimal,
prior_offset_cumulative: Decimal,
as_of: datetime,
min_delta: Decimal = Decimal("0.5"),
) -> Activity | None:
"""Compute the gains-offset DELTA since the last scrape and shape it
as a DEPOSIT (or WITHDRAWAL on a market drop).
The pension's per-fund prices aren't trackable in WF directly (no
public quote feed for these institutional life-fund share classes).
Instead, each monthly scrape emits a single small DEPOSIT/WITHDRAWAL
sized to ``(current_pot - real_contributions) - prior_cumulative_offset``
i.e., the growth (or loss) accrued since the last run.
Wealthfolio's net_contribution then incorrectly includes all these
offsets; the ``dav_corrected`` PG view subtracts them back out so the
dashboard's Growth/ROI panels remain accurate. The deterministic
external_id (per scrape date) lets re-runs of the same day overwrite
rather than stack duplicates.
"""
if not holdings:
return None
current_pot = sum((h.total_value for h in holdings), Decimal(0))
current_gain = current_pot - total_real_contribution
delta = current_gain - prior_offset_cumulative
if abs(delta) < min_delta:
return None
return Activity(
external_id=f"fidelity:gains-delta:{as_of.date().isoformat()}",
account_id=ACCOUNT_ID,
account_type=AccountType.WORKPLACE_PENSION,
date=as_of,
activity_type=ActivityType.DEPOSIT if delta > 0 else ActivityType.WITHDRAWAL,
currency=_CCY,
amount=abs(delta),
notes=(
f"fidelity-planviewer:unrealised-gains-offset delta=£{delta} "
f"(pot=£{current_pot}, contrib=£{total_real_contribution}, "
f"prior_offset=£{prior_offset_cumulative})"
),
)
def fidelity_holdings_to_snapshot(
holdings: list[FidelityHolding],
total_real_contribution: Decimal,
as_of: date,
) -> ManualSnapshotPayload | None:
"""Convert scraped holdings into a Wealthfolio manual snapshot payload.
Cost-basis allocation: PlanViewer doesn't expose historical purchase
prices for individual fund unit buys, so we approximate per-fund
cost basis by allocating the cumulative cash contribution
proportionally to each fund's share of the current pot value. For
the typical single-fund Meta scheme this is exact; if Viktor's plan
later splits into multiple funds the proportional split is the
least-wrong allocation we can compute from monthly snapshots.
cashBalances is set to zero pension contributions flow straight
into funds, the synthetic Wealthfolio "cash balance" only existed
because of the old gains-offset DEPOSIT hack.
"""
if not holdings:
return None
total_value = sum((h.total_value for h in holdings), Decimal(0))
if total_value <= 0:
return None
positions: list[SnapshotPosition] = []
for h in holdings:
share = h.total_value / total_value
cost = (total_real_contribution * share).quantize(Decimal("0.01"))
avg_cost = (cost / h.units).quantize(Decimal("0.0001")) if h.units > 0 else Decimal(0)
positions.append(SnapshotPosition(
symbol=h.fund_code,
quantity=h.units,
average_cost=avg_cost,
total_cost_basis=cost,
currency=h.currency,
))
return ManualSnapshotPayload(
date=as_of,
currency=_CCY,
positions=positions,
cash_balances={_CCY: Decimal(0)},
)
async def _scrape_live_session(
*,
state_path: str,
headless: bool,
) -> tuple[str, dict[str, Any]]:
"""Load storage_state, navigate the transaction + valuation pages,
return (transactions HTML, valuation JSON payload).
Raises :class:`FidelitySessionError` if the session is dead (15-min idle,
cookie expiry, etc.) Viktor must re-seed.
"""
from playwright.async_api import async_playwright
captured_valuation: dict[str, dict[str, Any]] = {}
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=headless)
try:
ctx = await browser.new_context(
storage_state=state_path,
user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/147.0.0.0 Safari/537.36"),
viewport={"width": 1280, "height": 900},
)
page = await ctx.new_page()
async def on_response(resp: Any) -> None:
if _PV_VALUATION_PATH in resp.url and resp.status < 400:
with contextlib.suppress(Exception):
captured_valuation["payload"] = await resp.json()
page.on("response", on_response)
# Trigger session + capture valuation by navigating through landing
# → main page. The SPA fires DisplayValuation on the main page.
await page.goto(_PV_LANDING, wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(2000)
main_url = f"{_PV_BASE}/planviewer/DisplayMainPage.action"
await page.goto(main_url, wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(3000)
if "idle for more than 15 minutes" in (await page.content()) \
or "id.fidelity.co.uk" in page.url:
raise FidelitySessionError(
"PlanViewer session stale — run `broker-sync fidelity-seed`")
# Now pull the transactions page with a wide date range.
await page.goto(f"{_PV_BASE}{_PV_TX_PATH}",
wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(1500)
await page.fill('input[name="startDate"]', _BACKFILL_START)
today = await page.evaluate(
"new Date().toLocaleDateString('en-GB',"
"{day:'2-digit',month:'short',year:'numeric'}).replace(/,/g,'')")
await page.fill('input[name="endDate"]', today)
await page.focus('input[name="endDate"]')
await page.keyboard.press("Enter")
with contextlib.suppress(Exception):
await page.wait_for_load_state("networkidle", timeout=15000)
await page.wait_for_timeout(2000)
tx_html = await page.content()
# If valuation wasn't picked up on the main page, request directly.
if "payload" not in captured_valuation:
r = await page.request.get(f"{_PV_BASE}{_PV_VALUATION_PATH}")
if r.ok:
with contextlib.suppress(Exception):
captured_valuation["payload"] = await r.json()
# Roll the storage_state so the next run benefits from any refresh.
await ctx.storage_state(path=state_path)
finally:
await browser.close()
valuation: dict[str, Any] = captured_valuation.get("payload") or {}
return tx_html, valuation

View file

@ -0,0 +1,144 @@
"""Backfill-from-finance provider.
The retired `finance` app's MySQL has a `position` table with 5+ years of
InvestEngine + Schwab trade history (2020 onwards) that the broker-sync
pipeline otherwise can't reconstruct (IE's emails only go back to when
Viktor started receiving them; Schwab emails are sparse). This provider
reads that table once and emits canonical Activities so a full-history
backfill into Wealthfolio is possible.
Ticker routing to Wealthfolio accounts:
*.L (VUAG.L, VUSA.L, etc.) -> InvestEngine ISA (GBP)
everything else (META, *_US_EQ) -> Schwab (US workplace, USD)
Deduplication: the finance.position PK (a giant numeric string) goes into
external_id verbatim, so re-runs are idempotent against the sync_record
store.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from decimal import Decimal
from typing import NamedTuple
import aiomysql # type: ignore[import-untyped]
from broker_sync.models import Account, AccountType, Activity, ActivityType
log = logging.getLogger(__name__)
IE_ACCOUNT_ID = "invest-engine-primary"
SCHWAB_ACCOUNT_ID = "schwab-workplace"
class FinanceMySQLCreds(NamedTuple):
host: str
port: int
user: str
password: str
database: str
def _route(ticker: str) -> tuple[str, AccountType, str]:
"""Return (account_id, account_type, currency) for a raw ticker."""
if ticker.endswith(".L"):
return IE_ACCOUNT_ID, AccountType.ISA, "GBP"
return SCHWAB_ACCOUNT_ID, AccountType.GIA, "USD"
def _normalise_symbol(ticker: str) -> str:
"""Strip finance-app quirks so the output symbol matches T212/Wealthfolio."""
# VUAG.L -> VUAG (LSE handled by Wealthfolio's exchange_mic resolution)
if ticker.endswith(".L"):
return ticker[:-2]
# FLME_US_EQ -> FLME (Trading212-style suffix leaked into the old finance DB)
if ticker.endswith("_US_EQ"):
return ticker[:-6]
if ticker.endswith("_EQ"):
return ticker[:-3]
return ticker
def _row_to_activity(row: dict[str, object]) -> Activity:
ticker = str(row["ticker"])
account_id, account_type, default_ccy = _route(ticker)
raw_qty = Decimal(str(row["num_shares"]))
activity_type = ActivityType.BUY if raw_qty > 0 else ActivityType.SELL
# buy_date from MySQL comes back as datetime (aiomysql converts)
dt = row["buy_date"]
if isinstance(dt, datetime):
date = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
else:
date = datetime.fromisoformat(str(dt)).replace(tzinfo=UTC)
currency_raw = row.get("currency")
currency = str(currency_raw) if currency_raw else default_ccy
return Activity(
external_id=f"finance-mysql:position:{row['id']}",
account_id=account_id,
account_type=account_type,
date=date,
activity_type=activity_type,
symbol=_normalise_symbol(ticker),
quantity=abs(raw_qty),
unit_price=Decimal(str(row["buy_price"])),
currency=currency,
notes=f"finance-mysql:{ticker}",
)
class FinanceMySQLProvider:
"""Read-only backfill from the retired finance MySQL `position` table."""
name = "finance-mysql"
def __init__(self, creds: FinanceMySQLCreds) -> None:
self._creds = creds
def accounts(self) -> list[Account]:
return [
Account(
id=IE_ACCOUNT_ID,
name="InvestEngine ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="invest-engine",
),
Account(
id=SCHWAB_ACCOUNT_ID,
name="Schwab (US workplace)",
account_type=AccountType.GIA,
currency="USD",
provider="schwab",
),
]
async def fetch(
self,
*,
since: datetime | None = None,
before: datetime | None = None,
) -> AsyncIterator[Activity]:
conn = await aiomysql.connect(
host=self._creds.host,
port=self._creds.port,
user=self._creds.user,
password=self._creds.password,
db=self._creds.database,
autocommit=True,
)
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("SELECT id, ticker, buy_price, num_shares, currency, buy_date, "
"account_id FROM position ORDER BY buy_date ASC")
rows = await cur.fetchall()
log.info("finance-mysql: %d position rows", len(rows))
for row in rows:
activity = _row_to_activity(row)
if since is not None and activity.date < since:
continue
if before is not None and activity.date >= before:
continue
yield activity
finally:
conn.close()

View file

@ -0,0 +1,276 @@
"""Interactive Brokers Flex Web Service ingestion provider.
Pulls daily Activity Flex Query reports via the ``ibflex`` library, maps
Trades + CashTransactions to broker-sync ``Activity`` objects, and runs a
reconciliation step against the broker-reported ``OpenPositions``.
See ``docs/specs/2026-05-26-ibkr-ingest-design.md`` for the full design.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from broker_sync.models import Account, AccountType, Activity, ActivityType
log = logging.getLogger(__name__)
# Map IBKR currency → default exchange suffix.
# Today: GBP → LSE (.L). Extend when more accounts onboard.
_LSE_EXCHANGES = {"LSE", "LSEETF", "LSEIOB1"}
_GBP_SUFFIX = ".L"
def canonical_symbol(symbol: str, *, exchange: str | None, currency: str) -> str:
"""Return the WF-canonical form of an IBKR ticker.
LSE-listed GBP instruments get a ``.L`` suffix (Wealthfolio convention).
US instruments and anything already suffixed are returned unchanged.
"""
if "." in symbol:
return symbol
if exchange in _LSE_EXCHANGES or (exchange is None and currency == "GBP"):
return symbol + _GBP_SUFFIX
return symbol
def _to_utc_datetime(value: Any, time_value: Any = None) -> datetime:
"""Combine a date (with optional time) into a UTC datetime."""
if isinstance(value, datetime):
dt = value
elif isinstance(value, date):
if isinstance(time_value, str):
dt = datetime.fromisoformat(f"{value.isoformat()}T{time_value}")
elif hasattr(time_value, "isoformat"):
dt = datetime.fromisoformat(f"{value.isoformat()}T{time_value.isoformat()}")
else:
dt = datetime.fromisoformat(f"{value.isoformat()}T00:00:00")
else:
# Last-resort: ISO string
dt = datetime.fromisoformat(str(value))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
def _map_trade_to_activity(trade: Any, *, account_id: str) -> Activity:
"""Map one ibflex Trade dataclass to a broker-sync Activity."""
buy_sell_obj = trade.buySell
buy_sell = buy_sell_obj.name if hasattr(buy_sell_obj, "name") else str(buy_sell_obj)
if buy_sell == "BUY":
activity_type = ActivityType.BUY
elif buy_sell == "SELL":
activity_type = ActivityType.SELL
else:
raise ValueError(
f"unsupported Trade.buySell={buy_sell!r} on tradeID={trade.tradeID}"
)
exchange = getattr(trade, "exchange", None)
symbol = canonical_symbol(
str(trade.symbol),
exchange=str(exchange) if exchange is not None else None,
currency=str(trade.currency),
)
quantity = abs(Decimal(str(trade.quantity)))
unit_price = Decimal(str(trade.tradePrice))
commission = trade.ibCommission if trade.ibCommission is not None else Decimal(0)
fee = abs(Decimal(str(commission)))
return Activity(
external_id=f"ibkr:trade:{trade.tradeID}",
account_id=account_id,
account_type=AccountType.GIA,
date=_to_utc_datetime(trade.tradeDate, getattr(trade, "tradeTime", None)),
activity_type=activity_type,
currency=str(trade.currency),
symbol=symbol,
quantity=quantity,
unit_price=unit_price,
fee=fee,
)
# Map known IBKR Flex CashTransaction.type values to broker-sync ActivityType.
# Unknown values yield None + a WARNING — we refuse to guess.
_CASH_TYPE_MAP: dict[str, ActivityType] = {
"DIVIDEND": ActivityType.DIVIDEND,
"DIVIDENDS": ActivityType.DIVIDEND,
"PAYMENT_IN_LIEU_OF_DIVIDENDS": ActivityType.DIVIDEND,
"WITHHOLDING_TAX": ActivityType.TAX,
"WHTAX": ActivityType.TAX,
"BROKER_INTEREST_RECEIVED": ActivityType.INTEREST,
"BROKER_INTEREST_PAID": ActivityType.FEE,
"COMMISSION_ADJUSTMENTS": ActivityType.FEE,
"OTHER_FEES": ActivityType.FEE,
}
_DEPOSIT_WITHDRAWAL_TYPES = {
"DEPOSITS_WITHDRAWALS",
"DEPOSIT_WITHDRAWALS",
"DEPOSITWITHDRAW",
}
def _normalise_cash_type(type_obj: Any) -> str:
"""Canonicalise the IBKR Flex CashTransaction.type enum to an UPPER_SNAKE name."""
if hasattr(type_obj, "name"):
return str(type_obj.name).upper()
return str(type_obj).strip().upper().replace(" ", "_").replace("&", "AND")
def _map_cash_to_activity(cash: Any, *, account_id: str) -> Activity | None:
"""Map one ibflex CashTransaction to a broker-sync Activity.
Returns None for unsupported types (logged at WARNING).
"""
type_name = _normalise_cash_type(cash.type)
amount = Decimal(str(cash.amount))
if type_name in _DEPOSIT_WITHDRAWAL_TYPES:
activity_type = ActivityType.DEPOSIT if amount > 0 else ActivityType.WITHDRAWAL
else:
mapped = _CASH_TYPE_MAP.get(type_name)
if mapped is None:
log.warning(
"ibkr: skipping cash transaction id=%s with unsupported type=%r",
getattr(cash, "transactionID", "?"),
type_name,
)
return None
activity_type = mapped
dt_raw = cash.dateTime
dt = _to_utc_datetime(dt_raw) if dt_raw is not None else datetime.now(UTC)
return Activity(
external_id=f"ibkr:cash:{cash.transactionID}",
account_id=account_id,
account_type=AccountType.GIA,
date=dt,
activity_type=activity_type,
currency=str(cash.currency),
amount=abs(amount),
)
class IBKRError(Exception):
"""Base class for ibkr-provider errors."""
class IBKRAccountMismatchError(IBKRError):
"""Flex statement accountId did not match configured upstream id."""
class IBKRProvider:
"""Fetches IBKR Flex Activity reports and yields broker-sync Activities.
Reconciliation (OpenPositions vs WF-computed qty) is NOT part of
``fetch()`` it runs at the CLI layer after import, where the
WealthfolioSink is available to query WF.
"""
name = "ibkr"
def __init__(
self,
*,
token: str,
query_id: str,
upstream_account_id: str,
) -> None:
self._token = token
self._query_id = query_id
# Single source of truth — the IBKR account number (e.g. U13279690).
# The pipeline's _ensure_accounts() resolves this to a Wealthfolio
# UUID via (provider="ibkr", providerAccountId=upstream_account_id);
# activities are remapped to the WF UUID before import.
self._upstream_account_id = upstream_account_id
# Stashed for the reconciliation step after fetch() drains.
self._last_response: Any = None
def accounts(self) -> list[Account]:
return [
Account(
id=self._upstream_account_id,
name="Interactive Brokers (UK)",
account_type=AccountType.GIA,
currency="GBP", # FX-aware per-trade; account ccy is GBP
provider="ibkr",
)
]
async def close(self) -> None:
# ibflex.client uses synchronous `requests` under the hood; no resources to close.
return
async def fetch(
self,
*,
since: datetime | None = None, # Flex query owns the date range
before: datetime | None = None,
) -> AsyncIterator[Activity]:
from ibflex import client as ib_client
from ibflex import parser as ib_parser
del since, before # unused; Flex query defines the period
xml_bytes = ib_client.download(self._token, self._query_id)
response = ib_parser.parse(xml_bytes)
self._last_response = response
if not response.FlexStatements:
log.warning("ibkr: Flex response had no FlexStatements")
return
stmt = response.FlexStatements[0]
if str(stmt.accountId) != self._upstream_account_id:
raise IBKRAccountMismatchError(
f"Flex statement.accountId={stmt.accountId!r} does not match "
f"configured IBKR_ACCOUNT_ID_UPSTREAM={self._upstream_account_id!r} "
f"— refusing to ingest"
)
for trade in stmt.Trades or []:
yield _map_trade_to_activity(trade, account_id=self._upstream_account_id)
for cash in stmt.CashTransactions or []:
activity = _map_cash_to_activity(cash, account_id=self._upstream_account_id)
if activity is not None:
yield activity
def open_positions(self) -> list[tuple[str, Decimal]]:
"""Return ``[(canonical_symbol, position_qty), ...]`` from the most
recent fetch. Empty list before the first ``fetch()`` call."""
if self._last_response is None:
return []
stmt = self._last_response.FlexStatements[0]
out: list[tuple[str, Decimal]] = []
for pos in stmt.OpenPositions or []:
exchange = getattr(pos, "exchange", None)
symbol = canonical_symbol(
str(pos.symbol),
exchange=str(exchange) if exchange is not None else None,
currency=str(pos.currency),
)
out.append((symbol, Decimal(str(pos.position))))
return out
def cash_balances(self) -> list[tuple[str, Decimal]]:
"""Return ``[(currency, ending_cash), ...]`` from the CashReport.
Includes the ``BASE_SUMMARY`` aggregate row (account base currency
consolidated) plus any per-currency rows. Empty list if no
CashReport section in the Flex query or before first ``fetch()``.
"""
if self._last_response is None:
return []
stmt = self._last_response.FlexStatements[0]
out: list[tuple[str, Decimal]] = []
for row in stmt.CashReport or []:
if row.endingCash is None or row.currency is None:
continue
out.append((str(row.currency), Decimal(str(row.endingCash))))
return out

View file

@ -0,0 +1,297 @@
"""IMAP email ingestor: dispatches messages to the matching parser by sender.
Used by the `imap-ingest` CLI command for InvestEngine + Schwab confirmation
emails. Each message passes through:
1. Pull ALL messages from the configured mailbox directory.
2. Route each by `From:` to a parser:
- noreply@investengine.com (+ equivalents) invest_engine parser
- Schwab confirmations (equityawards@schwab.com, etc.) schwab parser
3. Merge parser output into one list[Activity] with source attribution.
Not imap-idle; runs once per invocation. Designed for a daily CronJob.
"""
from __future__ import annotations
import email
import imaplib
import logging
import os
import re
import ssl
from collections.abc import AsyncIterator, Iterator
from datetime import date, datetime
from decimal import Decimal
from email.message import Message
from typing import NamedTuple
from broker_sync.models import Account, AccountType, Activity, ActivityType
from broker_sync.providers.parsers import invest_engine as ie_parser
from broker_sync.providers.parsers.schwab import parse_schwab_email
_IE_ISA_ACCOUNT_ID = "invest-engine-primary"
_IE_GIA_ACCOUNT_ID = "invest-engine-gia"
_ISA_ANNUAL_CAP = Decimal("20000")
_UK_TAX_YEAR_START = (4, 6) # (month, day) — UK tax year starts 6 April
def _uk_tax_year_start(d: datetime) -> date:
"""Return the start date (6 April of year N) of the UK tax year containing `d`."""
month, day = _UK_TAX_YEAR_START
cutoff = date(d.year, month, day)
return cutoff if d.date() >= cutoff else date(d.year - 1, month, day)
def _split_ie_by_isa_cap(
activities: list[Activity],
*,
isa_cap: Decimal = _ISA_ANNUAL_CAP,
) -> list[Activity]:
"""Re-route IE BUYs: first `isa_cap` GBP of each UK tax year → ISA, rest → GIA.
Viktor's IE account has both an ISA and a GIA wrapper, and his trade
confirmation emails don't indicate which one a given buy hit. Empirically,
he fills the ISA allowance first each tax year (6 April) and any excess
lands in GIA. This function partitions an already-parsed batch of Activity
objects by that rule.
Rule for boundary buys: a BUY is assigned to ISA iff the running tax-year
total BEFORE it is still strictly below the cap; otherwise GIA. Whole-
activity assignment no fractional splits.
Non-IE activities and non-BUYs are passed through unchanged.
"""
ie_buys = [
a for a in activities
if a.account_id == _IE_ISA_ACCOUNT_ID and a.activity_type is ActivityType.BUY
]
ie_buys.sort(key=lambda a: a.date)
cumulative: dict[date, Decimal] = {}
for a in ie_buys:
ty = _uk_tax_year_start(a.date)
running = cumulative.get(ty, Decimal(0))
trade_value = (a.quantity or Decimal(0)) * (a.unit_price or Decimal(0))
if running < isa_cap:
a.account_id = _IE_ISA_ACCOUNT_ID
a.account_type = AccountType.ISA
else:
a.account_id = _IE_GIA_ACCOUNT_ID
a.account_type = AccountType.GIA
cumulative[ty] = running + trade_value
return activities
log = logging.getLogger(__name__)
_IE_SENDERS = {"noreply@investengine.com", "hello@investengine.com"}
_SCHWAB_SENDERS = {
"equityawards@schwab.com",
"donotreply@schwab.com",
"wealthnotify@schwab.com",
}
_ADDR_RE = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
class ImapCreds(NamedTuple):
host: str
user: str
password: str
directory: str
def _extract_sender(msg: Message) -> str:
raw = msg.get("From", "")
m = _ADDR_RE.search(raw)
return (m.group(0) if m else "").lower()
def _html_or_text(msg: Message) -> str:
"""Return the richest body available (prefer HTML)."""
if msg.is_multipart():
html = None
plain = None
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/html" and html is None:
html = part.get_payload(decode=True)
elif ct == "text/plain" and plain is None:
plain = part.get_payload(decode=True)
body = html or plain
else:
body = msg.get_payload(decode=True)
if body is None:
return ""
if isinstance(body, bytes):
charset = msg.get_content_charset() or "utf-8"
try:
return body.decode(charset, errors="replace")
except LookupError:
return body.decode("utf-8", errors="replace")
return str(body)
def _fetch_all(creds: ImapCreds) -> Iterator[bytes]:
ctx = ssl.create_default_context()
with imaplib.IMAP4_SSL(creds.host, ssl_context=ctx) as m:
m.login(creds.user, creds.password)
typ, _ = m.select(creds.directory, readonly=True)
if typ != "OK":
raise RuntimeError(f"IMAP select {creds.directory} failed: {typ}")
typ, data = m.search(None, "ALL")
if typ != "OK":
raise RuntimeError(f"IMAP search failed: {typ}")
ids = data[0].split()
log.info("imap: fetching %d messages from %s", len(ids), creds.directory)
for uid in ids:
typ, rsp = m.fetch(uid, "(RFC822)")
if typ != "OK" or not rsp or not rsp[0]:
continue
raw = rsp[0][1]
if isinstance(raw, bytes):
yield raw
def _resolve_excluded_providers() -> set[str]:
"""Return the set of providers the IMAP fetcher must skip.
Default-exclude list is structural `invest-engine` is ALWAYS skipped
unless explicitly opted back in via `BROKER_SYNC_IMAP_INCLUDE_PROVIDERS`.
This protects against accidental re-ingestion via any code path that
doesn't set the cron's env (e.g. `kubectl run --rm`, devvm `poetry run`,
a sibling agent session). See post-mortem 2026-05-27 the IMAP path
re-inserted 39 IE BUYs that had been deduped the previous day, because
the safety lived only on the cronjob spec.
Additional providers can be excluded via
`BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS`. `INCLUDE` always wins over
`EXCLUDE` and the default skip-list.
"""
_DEFAULT_EXCLUDED = {"invest-engine", "invest_engine"}
extra = {
p.strip().lower().replace("_", "-")
for p in os.environ.get("BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS", "").split(",")
if p.strip()
}
include = {
p.strip().lower().replace("_", "-")
for p in os.environ.get("BROKER_SYNC_IMAP_INCLUDE_PROVIDERS", "").split(",")
if p.strip()
}
# Canonicalise the default set under the same key normalisation.
canonical = {p.replace("_", "-") for p in _DEFAULT_EXCLUDED}
return (canonical | extra) - include
def fetch_activities(creds: ImapCreds) -> list[Activity]:
out: list[Activity] = []
ie_parsed = schwab_parsed = ie_skipped = skipped = 0
exclude = _resolve_excluded_providers()
for raw in _fetch_all(creds):
try:
msg = email.message_from_bytes(raw)
except Exception:
skipped += 1
continue
sender = _extract_sender(msg)
if sender in _IE_SENDERS or sender.endswith("@investengine.com"):
if "invest-engine" in exclude:
ie_skipped += 1
continue
out.extend(ie_parser.parse_invest_engine_email(raw))
ie_parsed += 1
elif (
sender in _SCHWAB_SENDERS
or sender.endswith("@schwab.com")
or sender.endswith(".schwab.com") # e.g. donotreply@mail.schwab.com
):
if "schwab" in exclude:
skipped += 1
continue
html = _html_or_text(msg)
out.extend(parse_schwab_email(html))
schwab_parsed += 1
else:
skipped += 1
log.info(
"imap: ie_parsed=%d ie_skipped=%d schwab_parsed=%d skipped=%d%d activities",
ie_parsed,
ie_skipped,
schwab_parsed,
skipped,
len(out),
)
return out
class ImapProvider:
"""Wraps the IMAP fetch + per-sender parse into the Provider protocol.
Yields both InvestEngine AND Schwab activities downstream the
pipeline's dedup keyed on (provider, account, external_id) already
isolates them by account_id.
"""
name = "imap"
def __init__(self, creds: ImapCreds) -> None:
self._creds = creds
def accounts(self) -> list[Account]:
return [
Account(
id=_IE_ISA_ACCOUNT_ID,
name="InvestEngine ISA",
account_type=AccountType.ISA,
currency="GBP",
provider="invest-engine",
),
Account(
id=_IE_GIA_ACCOUNT_ID,
name="InvestEngine GIA",
account_type=AccountType.GIA,
currency="GBP",
provider="invest-engine",
),
Account(
id="schwab-workplace",
name="Schwab (US workplace)",
account_type=AccountType.GIA,
currency="USD",
provider="schwab",
),
]
async def fetch(
self,
*,
since: datetime | None = None,
before: datetime | None = None,
) -> AsyncIterator[Activity]:
# IMAP doesn't give us a server-side date range directly without
# constructing IMAP SEARCH criteria; filter client-side.
all_activities = fetch_activities(self._creds)
# Apply ISA/GIA £20k-cap routing in one batch-level pass so each UK tax
# year's cumulative total is computed consistently regardless of email
# order on the server.
routed = _split_ie_by_isa_cap(all_activities)
for a in routed:
if since is not None and a.date < since:
continue
if before is not None and a.date >= before:
continue
yield a
if __name__ == "__main__":
# Local smoke — invoked manually for debug, never from the CronJob.
import os
logging.basicConfig(level=logging.INFO)
c = ImapCreds(
host=os.environ["IMAP_HOST"],
user=os.environ["IMAP_USER"],
password=os.environ["IMAP_PASSWORD"],
directory=os.environ.get("IMAP_DIRECTORY", "INBOX"),
)
acts = fetch_activities(c)
print(f"total={len(acts)}")
for a in acts[:5]:
print(f" {a.activity_type} {a.symbol} {a.date.isoformat()}")

View file

@ -0,0 +1,129 @@
"""Parsers for Fidelity UK PlanViewer scraped data.
Two inputs:
- **Transactions HTML** from ``/planviewer/DisplayMyPlanMemberTransHist.action``
rendered with a wide date range. The relevant <table> has
``id="myplan_member_transhist_support"``.
- **Valuation JSON** from the XHR ``/planviewer/DisplayValuation.action``
the SPA calls this to render the my-investments dashboard. Contains
current unit holdings + price + breakdown by contribution type.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from bs4 import BeautifulSoup
_AMOUNT_RE = re.compile(r"\u00a3([\d,]+(?:\.\d+)?)")
# Fidelity transaction type strings we care about
_TX_DEPOSIT_TYPES = {
"regular premium",
"single premium",
"investment management rebate",
}
_TX_IGNORE_TYPES = {
"bulk switch", # pure reallocation, no cash impact
"fund switch",
}
@dataclass(frozen=True)
class FidelityCashTx:
"""A single cash-impacting transaction from the transaction history page."""
date: datetime
tx_type: str # raw Fidelity label ("Regular Premium", "Single Premium", …)
amount: Decimal
external_id: str
@dataclass(frozen=True)
class FidelityHolding:
"""A current fund-unit holding from DisplayValuation.action."""
fund_code: str
fund_name: str
units: Decimal
unit_price: Decimal
currency: str
total_value: Decimal
# Contribution-type breakdown ({"SASC": Decimal(...), "ERXS": Decimal(...)})
units_by_source: dict[str, Decimal]
def parse_transactions_html(html: str) -> list[FidelityCashTx]:
"""Extract cash-impacting transactions from the transaction history page.
Skips bulk switches (no cash movement) and header/total rows. Deterministic
external_id so re-runs dedup against the same rows.
"""
soup = BeautifulSoup(html, "html.parser")
out: list[FidelityCashTx] = []
for tr in soup.select("table#myplan_member_transhist_support tr"):
cells = [td.get_text(" ", strip=True) for td in tr.find_all("td")]
if len(cells) != 7:
continue
date_str, tx_type, _f, _c, _u, _p, amount_str = cells
m_date = re.match(r"(\d{2})/(\d{2})/(\d{4})", date_str)
if not m_date:
continue
tx_lower = tx_type.lower()
if tx_lower in _TX_IGNORE_TYPES or tx_type in ("-",):
continue
m_amt = _AMOUNT_RE.search(amount_str)
if not m_amt:
continue
amount = Decimal(m_amt.group(1).replace(",", ""))
if amount == 0:
continue
dd, mm, yyyy = m_date.groups()
dt = datetime(int(yyyy), int(mm), int(dd), tzinfo=UTC)
fp = hashlib.sha256(
f"{dt.isoformat()}|{tx_type}|{amount}".encode()
).hexdigest()[:16]
out.append(FidelityCashTx(
date=dt,
tx_type=tx_type,
amount=amount,
external_id=f"fidelity:tx:{fp}",
))
return out
def parse_valuation_json(payload: Any) -> list[FidelityHolding]:
"""Extract current fund holdings from DisplayValuation.action JSON."""
out: list[FidelityHolding] = []
for v in payload.get("valuations", []):
asset = v.get("asset") or {}
fund_code = next(
(a.get("value") for a in asset.get("assetId", []) if a.get("type") == "FUND_CODE"),
None,
)
if not fund_code:
continue
fund_name = asset.get("name") or fund_code
units = Decimal(str((v.get("units") or {}).get("total") or 0))
price = (v.get("price") or {})
unit_price = Decimal(str(price.get("value") or 0))
currency = price.get("currency") or "GBP"
total = Decimal(str((v.get("valuation") or {}).get("total") or 0))
groups = (v.get("units") or {}).get("group", []) or []
by_src = {}
for g in groups:
if g.get("type") == "CONTRIBUTION_TYPE" and g.get("groupId"):
by_src[g["groupId"]] = Decimal(str(g.get("unit", {}).get("total") or 0))
out.append(FidelityHolding(
fund_code=fund_code,
fund_name=fund_name,
units=units,
unit_price=unit_price,
currency=currency,
total_value=total,
units_by_source=by_src,
))
return out

View file

@ -16,43 +16,77 @@ Every parse strategy produces canonical `Activity` objects with:
from __future__ import annotations from __future__ import annotations
import csv
import email import email
import hashlib import hashlib
import io
import re
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal, InvalidOperation
from email.message import Message from email.message import Message
from bs4 import BeautifulSoup
from broker_sync.models import AccountType, Activity, ActivityType from broker_sync.models import AccountType, Activity, ActivityType
_ACCOUNT_ID = "invest-engine-primary" _ACCOUNT_ID = "invest-engine-primary"
_CURRENCY_SIGN = "£" _CURRENCY_SIGN = "£"
# HTML trade summary rows have the shape "Bought <qty> @ £<price> per share".
_BOUGHT_RE = re.compile(
r"Bought\s+([0-9]+(?:\.[0-9]+)?)\s*@\s*" + re.escape(_CURRENCY_SIGN) + r"([0-9]+(?:\.[0-9]+)?)",
re.IGNORECASE,
)
# Ticker lines look like "Vanguard S&P 500: VUAG" — we want the last
# all-caps token after the colon.
_TICKER_RE = re.compile(r":\s*([A-Z][A-Z0-9]{1,9})\s*$")
# Date rows contain "Date: DD Month YYYY".
_DATE_RE = re.compile(
r"Date:\s*([0-9]{1,2})\s+([A-Za-z]+)\s+([0-9]{4})",
re.IGNORECASE,
)
def parse_invest_engine_email(raw_email: bytes) -> list[Activity]: def parse_invest_engine_email(raw_email: bytes) -> list[Activity]:
"""Parse an IE trade confirmation email into Activity records. """Parse an IE trade confirmation email into Activity records.
Returns an empty list when none of the three strategies match never Tries RFC 2822 body lines first, then HTML tables, then a CSV
attachment. Returns an empty list when nothing matches never
raises on malformed input. raises on malformed input.
""" """
msg = email.message_from_bytes(raw_email) msg = email.message_from_bytes(raw_email)
body = _extract_text_body(msg) text_body = _extract_part_body(msg, "text/plain")
if body is None: if text_body is not None:
activities = _parse_rfc2822_lines(text_body)
if activities:
return activities
html_body = _extract_part_body(msg, "text/html")
if html_body is not None:
activities = _parse_html_tables(html_body)
if activities:
return activities
csv_activities = _parse_csv_attachment(raw_email)
if csv_activities:
return csv_activities
return [] return []
return _parse_rfc2822_lines(body)
def _extract_text_body(msg: Message) -> str | None: def _extract_part_body(msg: Message, content_type: str) -> str | None:
"""Return the text/plain body of an email, or None if absent.""" """Return the first sub-part of the given content type, or None."""
if msg.is_multipart(): if msg.is_multipart():
for part in msg.walk(): for part in msg.walk():
if part.get_content_type() == "text/plain": if part.get_content_type() == content_type:
return _decode_payload(part)
return None
if msg.get_content_type() == content_type:
return _decode_payload(msg)
return None
def _decode_payload(part: Message) -> str | None:
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
if isinstance(payload, bytes): if isinstance(payload, bytes):
return payload.decode(part.get_content_charset() or "utf-8", errors="replace") return payload.decode(part.get_content_charset() or "utf-8", errors="replace")
return None
payload = msg.get_payload(decode=True)
if isinstance(payload, bytes):
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
if isinstance(payload, str): if isinstance(payload, str):
return payload return payload
return None return None
@ -63,7 +97,8 @@ def _parse_rfc2822_lines(body: str) -> list[Activity]:
Corresponds to `_extract_position_v1` and `_extract_position_v2` in Corresponds to `_extract_position_v1` and `_extract_position_v2` in
the upstream parser. Returns a one-element list on success, `[]` the upstream parser. Returns a one-element list on success, `[]`
otherwise. otherwise. v3/v4 are not ported no surviving fixtures exist and
the HTML fallback covers newer formats.
""" """
for parser in (_try_v2, _try_v1): for parser in (_try_v2, _try_v1):
result = parser(body) result = parser(body)
@ -121,6 +156,150 @@ def _try_v1(body: str) -> Activity | None:
) )
def _parse_html_tables(body: str) -> list[Activity]:
"""Parse an HTML body with per-order nested summary tables.
Walks every leaf <table> (a table with no child tables); each leaf
carries one trade summary (ticker, bought line, total, ISIN + order
id). Tables that don't contain the expected shape are skipped, so a
partially corrupted email yields only its intact orders.
"""
soup = BeautifulSoup(body, "html.parser")
on_date = _extract_html_date(soup)
if on_date is None:
return []
activities: list[Activity] = []
for table in soup.find_all("table"):
if table.find("table") is not None:
continue
activity = _try_html_summary_table(table, on_date)
if activity is not None:
activities.append(activity)
return activities
def _extract_html_date(soup: BeautifulSoup) -> datetime | None:
match = _DATE_RE.search(soup.get_text(" ", strip=True))
if match is None:
return None
day, month, year = match.groups()
try:
return datetime.strptime(f"{day}-{month}-{year}", "%d-%B-%Y")
except ValueError:
return None
def _try_html_summary_table(nested: object, on_date: datetime) -> Activity | None:
"""Interpret a leaf <table> as a single trade summary.
Returns None if the table is structural (no "Bought N @ £P" row) or
any required field is missing.
"""
get_text = getattr(nested, "get_text", None)
if get_text is None:
return None
text = get_text(" ", strip=True)
bought = _BOUGHT_RE.search(text)
if bought is None:
return None
symbol = _extract_html_symbol(nested)
if symbol is None:
return None
quantity = Decimal(bought.group(1))
unit_price = Decimal(bought.group(2))
return _build_activity(
on_date=on_date,
symbol=symbol,
quantity=quantity,
unit_price=unit_price,
strategy="html",
matched=text[:200],
)
def _extract_html_symbol(nested: object) -> str | None:
find_all = getattr(nested, "find_all", None)
if find_all is None:
return None
for cell in find_all("td"):
cell_text = cell.get_text(" ", strip=True)
m = _TICKER_RE.search(cell_text)
if m is not None:
return m.group(1)
return None
_CSV_CONTENT_TYPES = {"text/csv", "application/csv", "application/vnd.ms-excel"}
# Required columns for the CSV attachment strategy. IE has not (yet) sent
# CSV-attached statements in production — the column set here mirrors the
# upstream _extract_positions_csv contract (ticker, buy_price, num_shares,
# buy_date, currency) with modern names.
_CSV_COLUMNS = {"ticker", "unit_price", "quantity", "date", "currency"}
def _parse_csv_attachment(raw_email: bytes) -> list[Activity]:
"""Parse a CSV attachment from the email into Activity records.
Walks every MIME part, picks the first one with a CSV-ish content
type OR a `.csv` filename, and iterates its rows. Rows missing a
required column or with an unparseable number/date are skipped.
"""
msg = email.message_from_bytes(raw_email)
csv_text = _extract_csv_attachment_text(msg)
if csv_text is None:
return []
reader = csv.DictReader(io.StringIO(csv_text))
fieldnames = set(reader.fieldnames or [])
if not _CSV_COLUMNS.issubset(fieldnames):
return []
activities: list[Activity] = []
for row in reader:
activity = _csv_row_to_activity(row)
if activity is not None:
activities.append(activity)
return activities
def _extract_csv_attachment_text(msg: Message) -> str | None:
for part in msg.walk():
if not _looks_like_csv_part(part):
continue
payload = part.get_payload(decode=True)
if isinstance(payload, bytes):
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")
if isinstance(payload, str):
return payload
return None
def _looks_like_csv_part(part: Message) -> bool:
if part.get_content_type() in _CSV_CONTENT_TYPES:
return True
filename = part.get_filename()
return isinstance(filename, str) and filename.lower().endswith(".csv")
def _csv_row_to_activity(row: dict[str, str]) -> Activity | None:
try:
on_date = datetime.strptime(row["date"], "%Y-%m-%d")
symbol = row["ticker"].strip()
quantity = Decimal(row["quantity"])
unit_price = Decimal(row["unit_price"])
currency = row["currency"].strip() or "GBP"
except (KeyError, ValueError, InvalidOperation):
return None
if not symbol or currency != "GBP":
return None
return _build_activity(
on_date=on_date,
symbol=symbol,
quantity=quantity,
unit_price=unit_price,
strategy="csv",
matched=f"{symbol},{unit_price},{quantity},{row['date']}",
)
def _build_activity( def _build_activity(
*, *,
on_date: datetime, on_date: datetime,

View file

@ -0,0 +1,125 @@
"""Schwab workplace-RSU email parser.
Schwab Stock Plan Services sends a "Your trade was executed" email for
each sell-to-cover trade (and any user-initiated trade) on the workplace
account. The body has five `<td class="dark-background-body" align="right">`
cells holding date / direction / quantity / ticker / price.
It does NOT email vest-release / Release Confirmation messages to the
employee address for this account (verified against 4 years of inbox
history, 2022-2026). The vest itself is invisible to IMAP.
Same-day-sell synthesis: Meta RSUs vest and are sold the same day at
the same FMV (verified across 14 historical vests). When a SELL email
is parsed AND its trade date is on or after `VEST_INFER_FROM_DATE`,
we ALSO emit a paired BUY representing the underlying vest event
same date, same quantity, same price. The date boundary stops this
back-filling historical vests that already have csv-sourced BUY rows
in Wealthfolio (which would duplicate at chart-level despite distinct
external_ids).
On any parse failure we return an empty list an unparseable email
shouldn't crash the IMAP batch.
"""
from __future__ import annotations
import logging
import os
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from bs4 import BeautifulSoup
from dateutil import parser as dateparser
from broker_sync.models import AccountType, Activity, ActivityType
log = logging.getLogger(__name__)
_ACCOUNT_ID = "schwab-workplace"
_DEFAULT_CURRENCY = "USD"
# Inferred-BUY synthesis boundary. SELL emails on or after this date
# emit a paired BUY for the underlying vest; earlier ones do not (they
# already have csv-sourced BUYs in Wealthfolio from the one-shot
# historical backfill, last vest 2026-02-18). Override at runtime with
# the env var if a different cutover is needed. ISO-8601 yyyy-mm-dd.
_DEFAULT_VEST_INFER_FROM = "2026-04-01"
def _vest_infer_from() -> date:
raw = os.environ.get("SCHWAB_VEST_INFER_FROM_DATE", _DEFAULT_VEST_INFER_FROM).strip()
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
log.warning(
"SCHWAB_VEST_INFER_FROM_DATE=%r is not yyyy-mm-dd; using default %s",
raw, _DEFAULT_VEST_INFER_FROM,
)
return datetime.strptime(_DEFAULT_VEST_INFER_FROM, "%Y-%m-%d").date()
def parse_schwab_email(raw_html: str) -> list[Activity]:
"""Return Activities for a Schwab trade-executed email.
Returns: empty list on parse failure; one Activity for a BUY-direction
email (rare the workplace account is essentially sell-only); for a
SELL email, returns [SELL] plus an inferred paired BUY (=vest event)
when the trade date is on or after the synthesis-boundary date.
"""
try:
soup = BeautifulSoup(raw_html, "html.parser")
cells = [
td.get_text(strip=True) for td in soup.find_all("td", {
"class": "dark-background-body",
"align": "right"
})
]
if len(cells) < 5:
return []
date_txt, direction_txt, qty_txt, ticker, price_txt = cells[:5]
trade_date = dateparser.parse(date_txt)
direction = (ActivityType.SELL
if direction_txt.strip().lower() == "sold" else ActivityType.BUY)
quantity = Decimal(qty_txt.replace(",", "").strip())
price_clean = price_txt
for sign in ("$", "£", "", "USD", "GBP", "EUR"):
price_clean = price_clean.replace(sign, "")
unit_price = Decimal(price_clean.replace(",", "").strip())
ticker_clean = ticker.strip()
external_id = (f"schwab:{trade_date.date().isoformat()}:{ticker_clean}:"
f"{direction.value}:{quantity}")
primary = Activity(
external_id=external_id,
account_id=_ACCOUNT_ID,
account_type=AccountType.GIA,
date=trade_date,
activity_type=direction,
symbol=ticker_clean,
quantity=quantity,
unit_price=unit_price,
currency=_DEFAULT_CURRENCY,
notes=f"schwab-email:{direction_txt}",
)
if direction is not ActivityType.SELL or trade_date.date() < _vest_infer_from():
return [primary]
inferred_buy = Activity(
external_id=(f"schwab:vest:{trade_date.date().isoformat()}:"
f"{ticker_clean}:BUY:{quantity}"),
account_id=_ACCOUNT_ID,
account_type=AccountType.GIA,
date=trade_date,
activity_type=ActivityType.BUY,
symbol=ticker_clean,
quantity=quantity,
unit_price=unit_price,
currency=_DEFAULT_CURRENCY,
notes=(f"schwab-vest-inferred-from-same-day-sell | "
f"paired_sell_external_id={external_id}"),
)
return [inferred_buy, primary]
except (ValueError, InvalidOperation, IndexError, AttributeError):
return []

View file

@ -2,6 +2,9 @@ from __future__ import annotations
import json import json
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, date
from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -13,6 +16,8 @@ _LOGIN_PATH = "/api/v1/auth/login"
_ACCOUNTS_PATH = "/api/v1/accounts" _ACCOUNTS_PATH = "/api/v1/accounts"
_IMPORT_CHECK = "/api/v1/activities/import/check" _IMPORT_CHECK = "/api/v1/activities/import/check"
_IMPORT_REAL = "/api/v1/activities/import" _IMPORT_REAL = "/api/v1/activities/import"
_SNAPSHOTS_IMPORT = "/api/v1/snapshots/import"
_ACTIVITIES_SEARCH = "/api/v1/activities/search"
class WealthfolioError(Exception): class WealthfolioError(Exception):
@ -130,10 +135,7 @@ class WealthfolioSink:
""" """
existing = await self.list_accounts() existing = await self.list_accounts()
for a in existing: for a in existing:
if ( if (a.get("provider") == account.provider and a.get("providerAccountId") == account.id):
a.get("provider") == account.provider
and a.get("providerAccountId") == account.id
):
wf_id = a.get("id") wf_id = a.get("id")
assert isinstance(wf_id, str) assert isinstance(wf_id, str)
return wf_id return wf_id
@ -159,9 +161,7 @@ class WealthfolioSink:
created = resp.json() created = resp.json()
wf_id = created.get("id") wf_id = created.get("id")
if not isinstance(wf_id, str): if not isinstance(wf_id, str):
raise WealthfolioError( raise WealthfolioError(f"POST /accounts returned no id: {created}")
f"POST /accounts returned no id: {created}"
)
return wf_id return wf_id
# -- activity import -- # -- activity import --
@ -169,8 +169,11 @@ class WealthfolioSink:
@staticmethod @staticmethod
def _activity_to_import_row(a: Activity) -> dict[str, Any]: def _activity_to_import_row(a: Activity) -> dict[str, Any]:
"""Match Wealthfolio's ActivityImport struct (camelCase JSON).""" """Match Wealthfolio's ActivityImport struct (camelCase JSON)."""
# WF /import rejects naive datetimes with "Invalid date" (even though
# /import/check accepts them) — coerce to UTC if tzinfo is missing.
date = a.date if a.date.tzinfo is not None else a.date.replace(tzinfo=UTC)
row: dict[str, Any] = { row: dict[str, Any] = {
"date": a.date.isoformat(), "date": date.isoformat(),
"symbol": a.symbol or "$CASH", "symbol": a.symbol or "$CASH",
"activityType": str(a.activity_type), "activityType": str(a.activity_type),
"currency": a.currency, "currency": a.currency,
@ -213,15 +216,12 @@ class WealthfolioSink:
checked = check.json() checked = check.json()
if not isinstance(checked, list): if not isinstance(checked, list):
raise ImportValidationError( raise ImportValidationError(
f"Wealthfolio /import/check returned non-list: {type(checked).__name__}" f"Wealthfolio /import/check returned non-list: {type(checked).__name__}")
)
invalid = [r for r in checked if isinstance(r, dict) and r.get("errors")] invalid = [r for r in checked if isinstance(r, dict) and r.get("errors")]
if invalid: if invalid:
raise ImportValidationError( raise ImportValidationError(f"Wealthfolio /import/check flagged {len(invalid)} row(s); "
f"Wealthfolio /import/check flagged {len(invalid)} row(s); " f"first: {invalid[0]}")
f"first: {invalid[0]}"
)
# Drop any row the server marked is_valid=false (shouldn't happen # Drop any row the server marked is_valid=false (shouldn't happen
# without errors, but defensive). # without errors, but defensive).
valid_rows = [r for r in checked if isinstance(r, dict) and r.get("isValid")] valid_rows = [r for r in checked if isinstance(r, dict) and r.get("isValid")]
@ -229,10 +229,216 @@ class WealthfolioSink:
real = await self._request("POST", _IMPORT_REAL, json={"activities": valid_rows}) real = await self._request("POST", _IMPORT_REAL, json={"activities": valid_rows})
real.raise_for_status() real.raise_for_status()
raw = real.json() raw = real.json()
# Two observed response shapes:
# - {activities:[...], importRunId:"...", summary:{total,imported,skipped,...}}
# - bare list (older builds)
if isinstance(raw, dict) and "activities" in raw: if isinstance(raw, dict) and "activities" in raw:
got = raw["activities"] got = raw["activities"]
summary = raw.get("summary") if isinstance(raw.get("summary"), dict) else None
elif isinstance(raw, list):
got = raw
summary = None
else:
got = []
summary = None
# Summary.imported is THE truth. The `activities` field echoes input
# with errors annotated — its length equals input even when zero
# actually persisted.
if summary is not None:
imported_n = int(summary.get("imported", 0))
total_n = int(summary.get("total", len(valid_rows)))
dupes = int(summary.get("duplicates", 0))
skipped = int(summary.get("skipped", 0))
# Duplicates are expected on every re-run (the cron re-processes the
# full IMAP window each night) — treat (imported + duplicates) as
# accounted-for. Only fail if something was genuinely lost.
accounted = imported_n + dupes
if accounted < total_n:
err_msg = summary.get("errorMessage") or "no errorMessage"
raise ImportValidationError(f"Wealthfolio /import persisted {imported_n}/{total_n} "
f"(skipped={skipped} duplicates={dupes}). "
f"errorMessage: {err_msg}")
# Legacy silent-drop guard for no-summary responses.
elif valid_rows and not got:
first_warn = next(
(r.get("warnings") for r in checked if isinstance(r, dict) and r.get("warnings")),
None,
)
raise ImportValidationError(
f"Wealthfolio /import silently dropped all {len(valid_rows)} rows. "
f"First checked row: {checked[0] if checked else 'none'}. "
f"First warning: {first_warn}")
assert isinstance(got, list) assert isinstance(got, list)
return got return [r for r in got if isinstance(r, dict)]
if isinstance(raw, list):
return raw # -- activity lookups --
return []
async def cumulative_amount_with_notes_prefix(
self,
account_id: str,
notes_prefix: str,
) -> Decimal:
"""Sum the amount of DEPOSIT/WITHDRAWAL activities whose notes start
with ``notes_prefix``, signed (deposits positive, withdrawals negative).
Used by the Fidelity provider to compute the delta gains-offset:
``current_gain - cumulative_existing_offset`` becomes the new
DEPOSIT to emit on each monthly run.
"""
try:
resp = await self._request(
"POST", _ACTIVITIES_SEARCH,
json={"accountIds": [account_id], "page": 1, "pageSize": 500},
)
except Exception:
return Decimal(0)
if resp.status_code >= 400:
return Decimal(0)
payload = resp.json()
rows = payload.get("data", payload) if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return Decimal(0)
total = Decimal(0)
for r in rows:
if not isinstance(r, dict):
continue
notes = r.get("comment") or r.get("notes") or ""
if not isinstance(notes, str) or not notes.startswith(notes_prefix):
continue
amt_raw = r.get("amount")
if amt_raw is None:
continue
try:
amt = Decimal(str(amt_raw))
except Exception:
continue
atype = (r.get("activityType") or r.get("activity_type") or "").upper()
if atype == "WITHDRAWAL":
total -= amt
else:
total += amt
return total
async def compute_position_qty(self, account_id: str) -> dict[str, Decimal]:
"""Return per-symbol net position quantity (BUY/IN minus SELL/OUT) for
one account. Skips cash activities and unknown activity types.
Used by the IBKR reconciliation step to compare against broker-reported
OpenPositions.
"""
qty_by_symbol: dict[str, Decimal] = {}
page = 1
while True:
resp = await self._request(
"POST", _ACTIVITIES_SEARCH,
json={"accountIds": [account_id], "page": page, "pageSize": 500},
)
resp.raise_for_status()
payload = resp.json()
activities = payload.get("activities", []) if isinstance(payload, dict) else []
if not activities:
break
for act in activities:
if not isinstance(act, dict):
continue
symbol = act.get("symbol") or ""
if not symbol or symbol.startswith("$CASH"):
continue
act_type = act.get("activityType") or ""
sign: int
if act_type in {"BUY", "ADD_HOLDING", "TRANSFER_IN"}:
sign = 1
elif act_type in {"SELL", "REMOVE_HOLDING", "TRANSFER_OUT"}:
sign = -1
else:
continue
try:
qty = Decimal(str(act.get("quantity") or 0))
except Exception:
continue
qty_by_symbol[symbol] = qty_by_symbol.get(symbol, Decimal(0)) + sign * qty
total_pages = int(payload.get("totalPages") or 1) if isinstance(payload, dict) else 1
if page >= total_pages:
break
page += 1
return qty_by_symbol
# -- manual holdings snapshots --
async def push_manual_snapshots(
self,
account_id: str,
snapshots: list[ManualSnapshotPayload],
) -> dict[str, Any]:
"""Push manual holdings snapshots to /api/v1/snapshots/import.
Each snapshot carries a date + per-fund positions + cash balances.
Wealthfolio auto-creates any unknown asset symbol with
``kind=INVESTMENT, quoteMode=MANUAL, quoteCcy=<currency>`` and uses
the snapshot to derive holdings + valuation for that date bypassing
the activity-ledger derivation entirely for the targeted day.
Used by the Fidelity provider since PlanViewer exposes current
fund units + price but no per-trade history. Re-imports for the
same (account, date) overwrite in place.
"""
if not snapshots:
return {"snapshotsImported": 0, "snapshotsFailed": 0, "errors": []}
body = {
"accountId": account_id,
"snapshots": [_snapshot_to_payload(s) for s in snapshots],
}
resp = await self._request("POST", _SNAPSHOTS_IMPORT, json=body)
if resp.status_code >= 400:
try:
payload = resp.json()
except Exception:
payload = {"raw": resp.text}
raise WealthfolioError(
f"Wealthfolio /snapshots/import rejected: {payload}")
result = resp.json()
assert isinstance(result, dict)
failed = int(result.get("snapshotsFailed", 0))
if failed > 0:
raise WealthfolioError(
f"Wealthfolio /snapshots/import: {failed} snapshot(s) failed; "
f"errors={result.get('errors')}")
return result
@dataclass(frozen=True)
class SnapshotPosition:
"""A per-fund position row in a Wealthfolio manual snapshot."""
symbol: str
quantity: Decimal
average_cost: Decimal
total_cost_basis: Decimal
currency: str
@dataclass(frozen=True)
class ManualSnapshotPayload:
"""Sink-facing snapshot row. Mirrors the JSON shape WF expects."""
date: date
currency: str
positions: list[SnapshotPosition]
cash_balances: dict[str, Decimal]
def _snapshot_to_payload(s: ManualSnapshotPayload) -> dict[str, Any]:
"""Serialise a ManualSnapshotPayload into WF's import wire format."""
return {
"date": s.date.isoformat(),
"currency": s.currency,
"positions": [
{
"symbol": p.symbol,
"quantity": format(p.quantity, "f"),
"averageCost": format(p.average_cost, "f"),
"totalCostBasis": format(p.total_cost_basis, "f"),
"currency": p.currency,
}
for p in s.positions
],
"cashBalances": {k: format(v, "f") for k, v in s.cash_balances.items()},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
# Fidelity UK PlanViewer provider
Viktor's UK workplace pension is hosted at `pv.planviewer.fidelity.co.uk`. There
is no public API for individual members — the provider reverse-engineers the
private JSON backend at `prd.wiciam.fidelity.co.uk/cvmfe/api/*` that the SPA
itself calls, and uses Playwright only to keep a long-lived login session
alive.
## Architecture
```
┌─────────────┐ storage_state.json ┌──────────────────┐
│ Vault KV │◀─── (quarterly reseed) ───│ fidelity-seed │
│ broker-sync │ │ (headed browser) │
└──────┬──────┘ └──────────────────┘
│ ▲
│ loads on start │ Viktor runs once
▼ when session expires
┌────────────────────┐
│ Monthly CronJob │
│ broker-sync-fidelity│
└────────────┬────────┘
│ headless Chromium
┌─────────────────────────────────┐ ┌────────────────────────────────┐
│ pv.planviewer.fidelity.co.uk │◀─────│ navigate dashboard → capture │
│ (SPA) │ │ fresh sid/fid/tbid/rid headers │
└─────────────────────────────────┘ └──────────────┬─────────────────┘
┌───────────▼─────────────┐
│ httpx JSON calls │
│ prd.wiciam.../cvmfe/api│
└───────────┬─────────────┘
┌────────────────────▼────────────────────┐
│ DEPOSIT × N (employee + employer) │
│ BUY × N (fund unit purchases, per date) │
└────────────────────┬────────────────────┘
┌────────────────▼────────────────┐
│ Wealthfolio account │
│ type = WORKPLACE_PENSION │
│ currency = GBP │
└──────────────────────────────────┘
```
## One-time seed (Viktor)
```bash
# on your laptop (macOS / Linux with a desktop):
cd broker-sync
poetry install
poetry run playwright install chromium
poetry run broker-sync fidelity-seed --out /tmp/fidelity_storage_state.json
# chromium opens — log in to PlanViewer, tick "Remember device", press Enter
# stage to Vault
vault kv patch secret/broker-sync \
fidelity_storage_state=@/tmp/fidelity_storage_state.json \
fidelity_plan_id=<your-plan-id>
rm /tmp/fidelity_storage_state.json # don't leave credentials lying around
```
Re-seed when the monthly CronJob fails with `FidelitySessionError` (expect
every 30-90 days, depending on how long Fidelity honours the remember-device
cookie).
## One-time backfill
```bash
kubectl -n broker-sync create job fidelity-backfill \
--from=cronjob/broker-sync-fidelity
kubectl -n broker-sync logs -f job/fidelity-backfill
# expect: fidelity-ingest: fetched=N new=N imported=N failed=0
```
## Monthly cron
- Schedule: `0 3 5 * *` (3am UTC on the 5th of each month — after mid-month payroll settles in Viktor's scheme)
- CronJob: `broker-sync-fidelity` in namespace `broker-sync`
- Resource: small, ≤512 MiB memory (Chromium for ~2 min, then idle)
- Alert: `BrokerSyncFidelityFailed` fires on 2 consecutive failures
## Runbook — `BrokerSyncFidelityFailed`
1. Check pod logs: `kubectl -n broker-sync logs job/broker-sync-fidelity-<timestamp>`.
2. If the error is `FidelitySessionError`: session expired, re-run the seed on
Viktor's laptop (see above).
3. If the error is a 404 / 5xx from `prd.wiciam.fidelity.co.uk`: likely an API
path change. Check DevTools for the new endpoint, update the provider, ship
a new image.
4. If Playwright can't launch Chromium: check that the image still has Chromium
installed (`playwright install chromium` at build time).
## Data model notes
- **Salary sacrifice scheme**: all employee + employer contributions are
pre-tax from gross salary. No HMRC basic-rate relief line.
- Emits two `DEPOSIT` per month (employee, employer) with `comment` carrying
the source tag `fidelity:<doc-id>:<source>` for audit.
- Emits one `BUY` per fund unit purchase, `symbol` = Fidelity fund code / ISIN.
Units × unit price should reconcile to the cash deposited ±pennies.
## Not yet implemented
- Endpoint paths: waiting on Viktor's DevTools POST cURL for transactions +
holdings views. Until pasted, `fidelity-ingest` raises
`FidelityProviderConfigError` to fail loudly.
- Infra: CronJob + Vault secret wiring + Prometheus alert in
`infra/stacks/broker-sync/main.tf` — pending first successful manual run.

127
docs/providers/ibkr.md Normal file
View file

@ -0,0 +1,127 @@
# Provider: Interactive Brokers (IBKR Flex Web Service)
Pulls a daily Activity Flex Query via the [`ibflex`](https://github.com/csingley/ibflex)
library, maps Trades + CashTransactions to broker-sync Activities, and
reconciles broker-side OpenPositions against WF-computed quantities.
## When this runs
- K8s CronJob `broker-sync-ibkr` in the `broker-sync` namespace, daily 02:00 UK.
- Manual trigger:
```bash
kubectl -n broker-sync create job --from=cronjob/broker-sync-ibkr broker-sync-ibkr-manual-$(date +%s)
```
## Vault secrets — `secret/broker-sync`
| Key | Description |
|---|---|
| `ibkr_flex_token` | Flex Web Service token (1-year validity, rotate via IBKR Client Portal). |
| `ibkr_flex_query_id` | Activity Flex Query ID (57 digit number). |
| `ibkr_account_id` | Wealthfolio account UUID for "Interactive Brokers (UK)". |
| `ibkr_account_id_upstream` | IBKR-side account number (e.g. `U12345678`) — guards against wrong-account ingestion. |
ExternalSecret `broker-sync-secrets` syncs all keys from `secret/broker-sync`
to a K8s secret of the same name. New keys take ~15 min to propagate.
## IBKR Flex Query design
In IBKR Client Portal → Reports → Flex Queries → Activity Flex Query, create
a new query named `broker-sync-activity` with:
| Section | Required fields |
|---|---|
| Account Information | accountId |
| Trades | tradeID, tradeDate, tradeTime, symbol, buySell, quantity, tradePrice, currency, ibCommission, assetCategory, exchange |
| Cash Transactions | transactionID, dateTime, type, amount, currency, description |
| Open Positions | symbol, position, markPrice, currency, assetCategory, exchange |
| Securities Information | symbol, description, conid |
**Date Format:** `yyyy-MM-dd`. **Time Format:** `HH:mm:ss` (no timezone
suffix — ibflex 1.1 rejects timezone abbreviations in the time field).
**Date Range:** `Last 365 Days` — trailing window so a missed cron run
doesn't lose data. SyncRecordStore (keyed by `external_id`) makes
overlapping pulls idempotent. For a one-off historical backfill, widen
temporarily to `Year to Date` or `Custom Date Range`, run once, then
switch back.
## Cash type mapping
| IBKR Flex `CashTransaction.type` | broker-sync `ActivityType` |
|---|---|
| Dividends | DIVIDEND |
| Withholding Tax | TAX |
| Broker Interest Received | INTEREST |
| Broker Interest Paid | FEE |
| Commission Adjustments | FEE |
| Other Fees | FEE |
| Deposits & Withdrawals | DEPOSIT (amount > 0) / WITHDRAWAL (amount < 0) |
| anything else | skipped + WARNING logged (refuse to guess) |
## Dedup keys
- Trades: `external_id = "ibkr:trade:" + tradeID`
- Cash: `external_id = "ibkr:cash:" + transactionID`
Both are stable across re-runs; `dedup.SyncRecordStore` rejects already-
synced IDs.
## Symbol canonicalisation
LSE-listed GBP instruments get a `.L` suffix (Wealthfolio convention).
US instruments and anything already suffixed pass through unchanged.
The heuristic: `exchange in {LSE, LSEETF, LSEIOB1}` OR
`(exchange is None AND currency == GBP)` → suffix with `.L`. Edge cases
not yet covered (Euronext, XETRA) — extend `canonical_symbol` when those
holdings exist.
## Position reconciliation
Each run pushes to Pushgateway under job `broker-sync-ibkr`:
- `ibkr_position_drift_shares{symbol, account="ibkr-uk"}`
broker_qty wf_qty per asset.
- `ibkr_sync_last_success_timestamp_seconds` — unix timestamp.
Alerts (TODO, will be added to the monitoring stack on first
non-zero drift):
- `IBKRPositionDrift{symbol}``|drift| > 0.01` for >24h, Slack `#security`.
- `IBKRSyncStale` — timestamp > 36h old.
- `IBKRFlexTokenExpired` — Loki rule on the "code 1003" log line.
## Account guard
Before yielding any activities, the provider checks
`flex.accountId == IBKR_ACCOUNT_ID_UPSTREAM`. Mismatch → raises
`IBKRAccountMismatchError` and writes nothing. Prevents wrong-account
ingestion from a misconfigured query (e.g., someone replaced the token
with another user's by mistake).
## Token rotation
Flex tokens expire after 1 year. When the cron starts failing with
`ResponseCodeError(code=1003)`:
1. Sign in to IBKR Client Portal → Reports → Settings → Flex Web Service
→ regenerate token.
2. `vault kv patch secret/broker-sync ibkr_flex_token='<new-token>'`
3. ExternalSecrets controller picks up the new value within ~15 min; no
manual pod restart needed.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `IBKR_FLEX_TOKEN not provided` exit 2 | Vault has placeholder value or key missing | `vault kv patch secret/broker-sync ibkr_flex_token='<real-token>'` |
| `IBKRAccountMismatchError` | `ibkr_account_id_upstream` doesn't match the account in the Flex query | Re-check IBKR account number; fix the Vault value |
| `ResponseCodeError(code=1003)` | Flex token expired | See "Token rotation" above |
| `StatementGenerationTimeout` | IBKR side slow | Single retry built in; if it persists, try a smaller date range |
| `Can't convert '... TZ' to time` parser error | Flex query has Time Format with timezone suffix | Switch to `HH:mm:ss` (no TZ) in Flex query settings |
| `'ETF' is not a valid AssetClass` | ETF set in fixture not in ibflex enum | Use `STK` in fixtures (IBKR Flex categorises ETFs under STK) |
## References
- Spec: [`docs/specs/2026-05-26-ibkr-ingest-design.md`](../specs/2026-05-26-ibkr-ingest-design.md)
- Plan: [`docs/plans/2026-05-26-ibkr-flex-ingestion.md`](../plans/2026-05-26-ibkr-flex-ingestion.md)
- Library: <https://github.com/csingley/ibflex>
- IBKR Flex Web Service docs: <https://www.interactivebrokers.com/en/software/am/am/reports/flex_web_service.htm>

View file

@ -0,0 +1,328 @@
# IBKR Flex Ingestion — Design
**Date:** 2026-05-26
**Status:** Approved (brainstorming session 2026-05-26)
**Author:** Viktor + Claude (Opus 4.7)
**Implementation plan:** TBD (will be written next session via writing-plans skill)
## Context
Adds Interactive Brokers (IBKR UK / IE — stocks/ETFs only) as a new
broker-sync provider, pushing activities to Wealthfolio on a daily
schedule alongside the existing Trading 212 / InvestEngine / Fidelity
pipelines.
The user's IBKR account is **currently empty** (no positions, no trades).
This design covers the integration as it will run once the account is
funded and active. The initial backfill step in the setup checklist is a
no-op until the first IBKR trade.
This work is the structural follow-on from the 2026-05-26 Wealthfolio
dedup session, in which £252k of duplicated InvestEngine positions
accumulated silently in WF because the IMAP and API ingestion paths
emitted different `external_id` schemes and never reconciled against
broker-reported truth. The IBKR design bakes in **broker-vs-WF position
reconciliation from day one** — the missing capability that allowed the
IE drift to grow undetected.
## Decisions
### D1 — Use IBKR Flex Web Service (not Client Portal API / TWS)
Flex Web Service is a token-authenticated REST endpoint returning XML
statements. Suits unattended cron because:
- One-year token validity (no daily re-auth, unlike Client Portal Gateway).
- No sidecar / GUI / Java runtime needed.
- Designed for periodic batch reporting — the exact shape of our pipeline.
Client Portal Web API + `ibind` was considered and rejected: its Gateway
sidecar requires browser-based re-auth roughly every 24 hours, which is
incompatible with unattended scheduling.
### D2 — Library: `ibflex` (`csingley/ibflex` on PyPI)
Adds `ibflex = "^0.16"` to `pyproject.toml`. The library provides:
- `client.download(token, query_id) -> bytes` — handles Flex's 2-step
async API (`SendRequest``GetStatement` polling).
- `parser.parse(xml) -> FlexQueryResponse` — typed dataclasses for
`Trades`, `CashTransactions`, `OpenPositions`, `SecuritiesInfo`.
Fallback (Approach B): if `ibflex` proves to lag IBKR schema changes, drop
in raw `httpx` + `xml.etree`. Same provider shape; only the parsing
internals change.
### D3 — One CronJob, daily 02:00 UK, in `broker-sync` namespace
Matches the existing `broker-sync-trading212` cadence and placement. No
new namespace, no new image.
### D4 — Reconciliation is mandatory, not optional
Every run computes a per-asset quantity from the Flex
`OpenPositions` section and compares against WF's computed quantity from
activities. Drift is published as a Pushgateway metric. Cross-checking
broker truth is the line of defense against the IE-style silent
divergence we saw on 2026-05-26.
### D5 — One account, one query
Single Flex Activity Query covering Trades + Cash + Open Positions +
Securities. Single `Interactive Brokers (UK)` account in Wealthfolio.
Multiple accounts can be added later by parameterising the CLI command;
not in scope now.
## Architecture
```
broker-sync K8s namespace
├── CronJob broker-sync-ibkr (schedule: 0 2 * * *)
│ ├── env from broker-sync-secrets:
│ │ IBKR_FLEX_TOKEN, IBKR_FLEX_QUERY_ID, IBKR_ACCOUNT_ID,
│ │ WF_BASE_URL, WF_USERNAME, WF_PASSWORD
│ ├── PVC broker-sync-data-encrypted (shared with other broker-sync jobs)
│ └── image viktorbarzin/broker-sync:<tag> command = ["broker-sync", "ibkr"]
│ External calls
│ ├── HTTPS → ndcdyn.interactivebrokers.com (Flex Web Service)
│ ├── HTTP → wealthfolio.wealthfolio.svc (activities import + position read)
│ └── HTTP → pushgateway.monitoring.svc (drift + last-success metrics)
```
The provider is structurally identical to `broker-sync-trading212` and
the IE bearer-token path — same Vault → CronJob → provider → pipeline →
WF flow. Existing alerting (CronJob-failed, ExternalSecret-stale,
WF-sync-stale) applies transitively; we only add IBKR-specific alerts on
top.
## Components
| Path | Action | Description |
|---|---|---|
| `broker_sync/providers/ibkr.py` | NEW | `IBKRProvider` class implementing the `Provider` protocol. Maps Flex XML to `Activity[]`. ~200 LOC. |
| `broker_sync/cli.py` | MODIFY | New `@app.command("ibkr")` typer command, parallel to `trading212` and `invest-engine`. ~60 LOC. |
| `pyproject.toml` | MODIFY | Add `ibflex = "^0.16"` dependency. |
| `tests/providers/test_ibkr.py` | NEW | Fixture-based parsing tests, sign-conventions, position-drift math, account-id guard. |
| `infra/stacks/broker-sync/main.tf` | MODIFY | New `kubernetes_cron_job_v1.ibkr` resource. |
| Vault `secret/broker-sync` | MODIFY | Add `ibkr_flex_token`, `ibkr_flex_query_id`, `ibkr_account_id`. |
| Wealthfolio (one-time, manual) | NEW data | Create `Interactive Brokers (UK)` account; record its UUID in Vault. |
| `docs/providers/ibkr.md` | NEW | Production-facing provider docs (setup, query design, troubleshooting). Written after first successful run. |
## Data flow (per CronJob run)
1. **02:00 UK** — CronJob fires, pod starts with env from `broker-sync-secrets`.
2. **Download**`ibflex.client.download(token, query_id)` calls Flex
Web Service `SendRequest` + `GetStatement`. Typical 520 s. Library
handles retry/polling.
3. **Parse**`ibflex.parser.parse(xml)` produces a
`FlexQueryResponse`.
4. **Account guard** — two distinct identifiers exist:
- **IBKR_ACCOUNT_ID_UPSTREAM**: the IBKR-side account number
(e.g. `U12345678`), used to validate that the Flex report belongs to
the right account.
- **IBKR_ACCOUNT_ID** (alias: `ibkr_account_id` in Vault): the
Wealthfolio account UUID (e.g. `8a3f...`), used when posting
activities to WF.
Validate `stmt.accountId == os.environ["IBKR_ACCOUNT_ID_UPSTREAM"]`.
Refuse to ingest on mismatch — prevents wrong-account writes from a
misconfigured query.
5. **Map Trades → Activities**:
| Flex | Activity | Notes |
|---|---|---|
| `Trade.tradeID` | `external_id = "ibkr:trade:" + tradeID` | dedup key |
| `Trade.tradeDate + tradeTime` | `date` (UTC) | timezone normalised |
| `Trade.symbol` | `symbol` | canonicalised — LSE tickers get `.L` suffix |
| `Trade.buySell` (BUY / SELL) | `activity_type` | direct |
| `Trade.quantity` | `quantity` | always positive (broker-sync convention) |
| `Trade.tradePrice` | `unit_price` | |
| `Trade.currency` | `currency` | per-trade, multi-ccy supported |
| `Trade.ibCommission` | `fee = abs(ibCommission)` | always positive |
| `Trade.assetCategory` | (sanity check; skip if not in {STK, ETF}) |
6. **Map CashTransactions → Activities**:
| Flex `CashTransaction.type` | Activity `activity_type` | Notes |
|---|---|---|
| `Dividends` | `DIVIDEND` | |
| `Withholding Tax` | `FEE` | tag with `notes="wht:..."` |
| `Broker Interest Paid` | `FEE` | negative direction |
| `Broker Interest Received` | `DIVIDEND` | interest treated as income |
| `Deposits & Withdrawals` | `DEPOSIT` (amount > 0) or `WITHDRAWAL` (amount < 0) | |
| `Commission Adjustments` | `FEE` | |
| anything else | skip + log WARNING with the unknown type | refuse to guess, same convention as IE provider |
external_id = `"ibkr:cash:" + transactionID`.
7. **Cash-flow match**`_with_cash_flow_match(a)` from the shared
pipeline emits a matching DEPOSIT for every BUY (and WITHDRAWAL for
every SELL) so WF cash balance stays consistent. This is the existing
pattern used by T212 + IE; IBKR slots in identically.
8. **Dedup**`SyncRecordStore(/data/sync.db)` skips any `external_id`
already synced. Idempotent re-runs are safe.
9. **Import**`WealthfolioSink.import_activities(...)` POSTs to
`/api/v1/activities/import`. Existing 401 retry logic applies.
10. **Reconciliation** — for each `OpenPositions` row:
```python
# compute_wf_position_qty: NEW helper in WealthfolioSink.
# Queries POST /api/v1/activities/search filtered by accountId, sums
# BUY/SELL/ADD_HOLDING/REMOVE_HOLDING quantities per asset.
wf_qty_by_asset = wf_sink.compute_position_qty(IBKR_ACCOUNT_ID)
for pos in flex_response.OpenPositions:
symbol = canonical_symbol(pos.symbol)
drift = float(pos.position) - wf_qty_by_asset.get(symbol, Decimal(0))
push_metric(
"ibkr_position_drift_shares",
labels={"symbol": symbol, "account": "ibkr-uk"},
value=float(drift),
)
push_metric("ibkr_sync_last_success_timestamp_seconds", time.time())
```
11. **Exit 0** on success, non-zero on any unrecoverable error.
## Error handling
| Failure | Detection | Response | Alert |
|---|---|---|---|
| Token expired (Flex code 1003) | `ibflex.client.ResponseCodeError` | Exit non-zero with explicit log | `IBKRFlexTokenExpired` Loki rule + stale-success Prom alert |
| Statement generation timeout | `ibflex.client.StatementGenerationTimeout` | Retry once after 60 s, then exit non-zero | Stale-success alert catches it after 24 h |
| Empty report (quiet day) | Zero Trades + zero CashTxns | Log "no new activity", still update success timestamp, still reconcile | (none — happy path) |
| WF API 401 | HTTP status | Re-login via `WealthfolioSink` (existing logic) | (existing) |
| WF rejects an activity row | `summary.skipped > 0` | Log per-row + exit non-zero | `IBKRImportRejected` Loki rule |
| Network / DNS fail | httpx exception | Retry once with 30 s backoff | `KubeJobFailed` (existing) |
| **Position drift > 0.01 share for >24h** | Pushgateway non-zero across runs | Prom alert `IBKRPositionDrift{symbol}` warning → Slack `#security` | **NEW capability** |
| Account ID mismatch | Flex `accountId` != env var | Exit 2 immediately, write nothing | `IBKRAccountMismatch` urgent Loki rule |
## Setup checklist (one-time)
### Step 1 — IBKR Client Portal (manual, ~5 min)
1. Sign in at `https://www.interactivebrokers.co.uk/` → **Account
Settings**.
2. **Reports → Settings → Flex Web Service** → Enable → copy the
one-time-displayed **Token** (1 year validity).
3. **Reports → Flex Queries → Activity Flex Query → Create New**:
- Name: `broker-sync-activity`
- Sections: `Account Information`, `Trades`, `Cash Transactions`,
`Open Positions`, `Securities Information`
- Date Format: `yyyy-MM-dd` · Time Format: `HH:mm:ss TimeZone`
- Date Range: `Last 365 Days` — trailing window so a missed cron run
(failed pod, outage, vacation) doesn't lose data. SyncRecordStore
keys on `ibkr:trade:<tradeID>` / `ibkr:cash:<transactionID>`, so
overlapping pulls are no-ops. `Last Business Day` was the original
choice but creates a "single missed run = permanent data loss"
failure mode — rejected in favour of dedup-backed resync window.
- Format: XML
- Trade fields: ensure `tradeID`, `tradeDate`, `tradeTime`, `symbol`,
`buySell`, `quantity`, `tradePrice`, `currency`, `ibCommission`,
`assetCategory` selected.
- CashTransaction fields: `transactionID`, `dateTime`, `type`,
`amount`, `currency`, `description`.
- OpenPositions fields: `symbol`, `position`, `markPrice`, `currency`,
`assetCategory`.
- Save → copy the **Query ID** (57 digit number).
### Step 2 — Vault
```bash
vault kv patch secret/broker-sync \
ibkr_flex_token='YOUR_TOKEN' \
ibkr_flex_query_id='YOUR_QUERY_ID' \
ibkr_account_id='WF_UUID_FROM_STEP_3' \
ibkr_account_id_upstream='YOUR_IBKR_ACCOUNT_NUMBER'
```
### Step 3 — Create WF account (script + paste UUID back)
```bash
# Login → POST /accounts → capture id
curl -sS -c /tmp/wf-jar -X POST "$WF_BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' -d "{\"password\":\"$WF_PASSWORD\"}"
curl -sS -b /tmp/wf-jar -X POST "$WF_BASE_URL/api/v1/accounts" \
-H 'Content-Type: application/json' \
-d '{"name":"Interactive Brokers (UK)","accountType":"GIA","currency":"GBP","isActive":true}' \
| jq -r '.id'
# Paste the UUID back into Vault under ibkr_account_id
```
### Step 4 — Initial backfill (skip while account is empty)
When the IBKR account first holds positions, the daily CronJob will
backfill automatically up to the 365-day trailing window. For older
history, temporarily switch the Flex query Date Range to
`Year to Date` (or `Custom Date Range` with a 1-year window), run the
CronJob manually once, verify WF totals match the broker app, then
switch the Flex query back to `Last 365 Days` for daily incremental.
Dedup makes the temporary widening safe — already-synced rows are no-ops.
### Step 5 — Deploy
1. Push to broker-sync `main` (direct push — personal repo convention,
no PR) → GHA builds `viktorbarzin/broker-sync:latest`.
2. `cd infra/stacks/broker-sync && scripts/tg apply` creates the new
CronJob.
3. Wait for the 02:00 UK run, or trigger manually:
`kubectl -n broker-sync create job --from=cronjob/broker-sync-ibkr broker-sync-ibkr-test-1`.
4. Verify in WF UI: account exists, activities present (if any),
reconciliation drift metric showing zero.
## Testing
**Unit tests** in `tests/providers/test_ibkr.py`:
- `test_parse_trades_maps_to_activities` — canned 3-trade XML, verify
external_id, symbol mapping, quantity sign, fee sign.
- `test_parse_dividend_maps_to_dividend_activity`.
- `test_parse_unknown_cash_type_logs_warning_and_skips`.
- `test_account_id_mismatch_raises` — Flex returns a different
`accountId` than env, refuse to ingest.
- `test_position_drift_computed_correctly` — three-asset scenario, two
match, one drifts.
- `test_canonical_symbol_lse_suffix``VUAG``VUAG.L`,
`AAPL``AAPL` (US, no suffix), etc.
All tests mock `ibflex.client.download` to avoid network.
**Integration test** (manual, post-deploy):
- Trigger CronJob manually.
- Inspect logs.
- Verify in WF UI and Pushgateway.
## Acceptance criteria
- [ ] `broker-sync ibkr` command runs end-to-end against the real Flex Web
Service with the user's token.
- [ ] WF accepts the resulting activity imports (no `summary.skipped`).
- [ ] `ibkr_position_drift_shares` is published for every asset; drift = 0
on a steady-state run.
- [ ] Re-running the command is idempotent — no duplicate activities
written to WF.
- [ ] CronJob completes successfully on its schedule for 7 consecutive days
before the design is marked Done.
## Out of scope
- Multi-account support (only one IBKR account designed in).
- Real-time data / order placement (Flex is batch-only).
- Stock split / corporate action handling — IBKR reports these in the
Flex `CorporateActions` section but we're not enabling that section
yet; revisit if it becomes needed.
- Multi-currency FX conversion math — we record per-trade currency
faithfully and let Wealthfolio do FX. If WF's FX handling proves
inadequate, a separate spec covers that.
## Open questions
(None at design-approval time. Captured here for future amendments.)
## References
- `ibflex` library docs (csingley/ibflex)
- Existing patterns in `broker_sync/providers/trading212.py` and
`broker_sync/providers/invest_engine.py`
- `~/code/infra/stacks/broker-sync/main.tf` (CronJob structure to mirror)
- 2026-05-26 Wealthfolio dedup session (motivates the reconciliation step)

359
poetry.lock generated
View file

@ -1,5 +1,24 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiomysql"
version = "0.3.2"
description = "MySQL driver for asyncio."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2"},
{file = "aiomysql-0.3.2.tar.gz", hash = "sha256:72d15ef5cfc34c03468eb41e1b90adb9fd9347b0b589114bd23ead569a02ac1a"},
]
[package.dependencies]
PyMySQL = ">=1.0"
[package.extras]
rsa = ["PyMySQL[rsa] (>=1.0)"]
sa = ["sqlalchemy (>=1.3,<1.4)"]
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.13.0" version = "4.13.0"
@ -54,6 +73,145 @@ files = [
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.8" version = "8.1.8"
@ -82,6 +240,79 @@ files = [
] ]
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "greenlet"
version = "3.4.0"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"},
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"},
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"},
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"},
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"},
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"},
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"},
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"},
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"},
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"},
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"},
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"},
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"},
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"},
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"},
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"},
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -142,6 +373,24 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"] zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "ibflex"
version = "1.1"
description = "Parse Interactive Brokers Flex XML reports and convert to Python types"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "ibflex-1.1-py3-none-any.whl", hash = "sha256:c84e02dafcd17f70587777c2e2f00e3cc1e949e045790bf4fe562fb03dbef434"},
{file = "ibflex-1.1.tar.gz", hash = "sha256:3e5cac02cadcbd22ea46ae4ca306d67c274b7166f40119f5d7d7103a130d032a"},
]
[package.dependencies]
requests = {version = "*", optional = true, markers = "extra == \"web\""}
[package.extras]
web = ["requests"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@ -428,6 +677,28 @@ files = [
{file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"},
] ]
[[package]]
name = "playwright"
version = "1.58.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606"},
{file = "playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71"},
{file = "playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117"},
{file = "playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b"},
{file = "playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa"},
{file = "playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99"},
{file = "playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8"},
{file = "playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b"},
]
[package.dependencies]
greenlet = ">=3.1.1,<4.0.0"
pyee = ">=13,<14"
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -444,6 +715,24 @@ files = [
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"] testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pyee"
version = "13.0.1"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" version = "2.20.0"
@ -459,6 +748,22 @@ files = [
[package.extras] [package.extras]
windows-terminal = ["colorama (>=0.4.6)"] windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pymysql"
version = "1.1.2"
description = "Pure Python MySQL Driver"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9"},
{file = "pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03"},
]
[package.extras]
ed25519 = ["PyNaCl (>=1.4.0)"]
rsa = ["cryptography"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.2" version = "8.4.2"
@ -515,6 +820,28 @@ files = [
[package.dependencies] [package.dependencies]
six = ">=1.5" six = ">=1.5"
[[package]]
name = "requests"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"
@ -628,6 +955,18 @@ rich = ">=10.11.0"
shellingham = ">=1.3.0" shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3" typing-extensions = ">=3.7.4.3"
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260408"
description = "Typing stubs for python-dateutil"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f"},
{file = "types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582"},
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@ -640,6 +979,24 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]] [[package]]
name = "yapf" name = "yapf"
version = "0.43.0" version = "0.43.0"
@ -658,4 +1015,4 @@ platformdirs = ">=3.5.1"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11,<3.13" python-versions = ">=3.11,<3.13"
content-hash = "b9c19ac1963682740a98cd539d3790ff180c2e8195d5cfcc9572da855db3fa7d" content-hash = "8a704e79729d5bd3cbe78a7e35c51e9da724880915c0152788273b94bd00610d"

View file

@ -13,6 +13,14 @@ beautifulsoup4 = "^4.12"
python-dateutil = "^2.9" python-dateutil = "^2.9"
typer = "^0.12" typer = "^0.12"
click = "<8.2" # typer 0.12 uses make_metavar() without ctx; click 8.2 made ctx required click = "<8.2" # typer 0.12 uses make_metavar() without ctx; click 8.2 made ctx required
aiomysql = "^0.3.2"
# Fidelity UK PlanViewer has no public API — we use Playwright only to keep a
# long-lived session alive (storage_state + device-trust cookie); actual data
# is fetched via httpx against the SPA's private JSON backend.
playwright = "^1.47"
# IBKR Flex Web Service: pulls Activity Flex Query XML reports (token-auth)
# and parses to typed dataclasses. No Gateway / daily re-auth needed.
ibflex = { version = "^1.1", extras = ["web"] }
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3" pytest = "^8.3"
@ -20,6 +28,7 @@ pytest-asyncio = "^0.23"
mypy = "^1.11" mypy = "^1.11"
ruff = "^0.6" ruff = "^0.6"
yapf = "^0.43" yapf = "^0.43"
types-python-dateutil = "^2.9.0.20260408"
[tool.poetry.scripts] [tool.poetry.scripts]
broker-sync = "broker_sync.cli:app" broker-sync = "broker_sync.cli:app"

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
{"valuations":[{"asset":{"assetId":[{"type":"FUND_CODE","value":"KDOA"}],"name":"Passive Global Equity Fund - Class 9"},"units":{"total":44920.21,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"BONW","type":"CONTRIBUTION_TYPE","name":"Bonus Waiver","unit":{"total":11490.84,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","unit":{"total":17148.27,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","unit":{"total":11432.20,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"TREX","type":"CONTRIBUTION_TYPE","name":"Transfer In","unit":{"total":4848.90,"available":null,"crystallised":null,"uncrystallised":null}}]},"price":{"value":3.066,"datetime":"2026-04-17","currency":"GBP"},"valuation":{"total":137725.35,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"BONW","type":"CONTRIBUTION_TYPE","name":"Bonus Waiver","valuation":{"total":35230.91,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","valuation":{"total":52576.60,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","valuation":{"total":35051.12,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"TREX","type":"CONTRIBUTION_TYPE","name":"Transfer In","valuation":{"total":14866.72,"available":null,"crystallised":null,"uncrystallised":null}}],"valuationType":"Value"},"currency":"GBP"},{"asset":{"assetId":[{"type":"FUND_CODE","value":"KCVT"}],"name":"FutureWise Target 2065 - Class 10"},"units":{"total":230.02,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","unit":{"total":153.35,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","unit":{"total":76.67,"available":null,"crystallised":null,"uncrystallised":null}}]},"price":{"value":3.254,"datetime":"2026-04-17","currency":"GBP"},"valuation":{"total":748.48,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","valuation":{"total":498.99,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","valuation":{"total":249.49,"available":null,"crystallised":null,"uncrystallised":null}}],"valuationType":"Value"},"currency":"GBP"},{"asset":{"assetId":[{"type":"FUND_CODE","value":"LAFC"}],"name":"Volatility Managed Multi Asset Fund"},"units":{"total":106.64,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","unit":{"total":71.09,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","unit":{"total":35.55,"available":null,"crystallised":null,"uncrystallised":null}}]},"price":{"value":252.9000,"datetime":"2026-04-17","currency":"GBP"},"valuation":{"total":269.70,"available":null,"crystallised":null,"uncrystallised":null,"group":[{"groupId":"ERXS","type":"CONTRIBUTION_TYPE","name":"Company","valuation":{"total":179.80,"available":null,"crystallised":null,"uncrystallised":null}},{"groupId":"SASC","type":"CONTRIBUTION_TYPE","name":"Salary Sacrifice","valuation":{"total":89.90,"available":null,"crystallised":null,"uncrystallised":null}}],"valuationType":"Value"},"currency":"GBP"}],"valuationSum":{"total":138743.53,"available":0.0,"crystallised":null,"uncrystallised":null,"currency":"GBP"},"asOfDateTime":"2026-04-17T12:00:00+01:00"}

25
tests/fixtures/ibkr/sample_flex.xml vendored Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<FlexQueryResponse queryName="broker-sync-activity" type="AF">
<FlexStatements count="1">
<FlexStatement accountId="U12345678" fromDate="2026-05-20" toDate="2026-05-26" period="LastBusinessDay" whenGenerated="2026-05-26T02:00:00">
<AccountInformation accountId="U12345678" acctAlias="" currency="GBP" name="Viktor Test" accountType="Individual"/>
<Trades>
<Trade tradeID="T1001" tradeDate="2026-05-21" tradeTime="14:30:00" symbol="VUAG" buySell="BUY" quantity="10" tradePrice="107.50" currency="GBP" ibCommission="-1.05" assetCategory="STK" exchange="LSEETF"/>
<Trade tradeID="T1002" tradeDate="2026-05-22" tradeTime="09:15:00" symbol="AAPL" buySell="BUY" quantity="5" tradePrice="180.25" currency="USD" ibCommission="-0.50" assetCategory="STK" exchange="NASDAQ"/>
<Trade tradeID="T1003" tradeDate="2026-05-23" tradeTime="11:00:00" symbol="VUAG" buySell="SELL" quantity="2" tradePrice="108.00" currency="GBP" ibCommission="-0.30" assetCategory="STK" exchange="LSEETF"/>
</Trades>
<CashTransactions>
<CashTransaction transactionID="C5001" dateTime="2026-05-22 12:00:00" type="Dividends" amount="3.50" currency="GBP" description="VUAG DIV"/>
<CashTransaction transactionID="C5002" dateTime="2026-05-22 12:00:00" type="Withholding Tax" amount="-0.35" currency="GBP" description="VUAG WHT"/>
</CashTransactions>
<OpenPositions>
<OpenPosition symbol="VUAG" position="8" markPrice="108.20" currency="GBP" assetCategory="STK"/>
<OpenPosition symbol="AAPL" position="5" markPrice="181.00" currency="USD" assetCategory="STK"/>
</OpenPositions>
<CashReport>
<CashReportCurrency accountId="U12345678" currency="BASE_SUMMARY" levelOfDetail="BaseCurrency" startingCash="1.23" endingCash="1.23" endingSettledCash="1.23"/>
<CashReportCurrency accountId="U12345678" currency="USD" levelOfDetail="Currency" startingCash="1.23" endingCash="1.23" endingSettledCash="1.23"/>
</CashReport>
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>

View file

@ -0,0 +1,22 @@
From: InvestEngine <no-reply@investengine.com>
To: viktorbarzin@example.com
Subject: Your InvestEngine statement
Date: Mon, 07 Apr 2025 09:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="----=_MIXED_1"
------=_MIXED_1
Content-Type: text/plain; charset=UTF-8
Your monthly statement is attached as a CSV.
------=_MIXED_1
Content-Type: text/csv; charset=UTF-8; name="statement.csv"
Content-Disposition: attachment; filename="statement.csv"
ticker,unit_price,quantity,date,currency
VUAG,63.21,12.5,2025-04-02,GBP
SWDA,86.40,4.75,2025-04-03,GBP
VUSA,90.10,1.0,2025-04-04,GBP
------=_MIXED_1--

View file

@ -0,0 +1,40 @@
From: InvestEngine <no-reply@investengine.com>
To: viktorbarzin@example.com
Subject: Your portfolio has been updated
Date: Wed, 15 Apr 2026 11:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_Part_PM"
------=_Part_PM
Content-Type: text/plain; charset=UTF-8
(HTML-only view — your client does not render HTML emails.)
------=_Part_PM
Content-Type: text/html; charset=UTF-8
<html><body>
<table><tr><td>Logo</td></tr></table>
<table>
<tr><td> Date: 15 April 2026 </td></tr>
<tr>
<td>
<table>
<tr><td>Vanguard S&amp;P 500: VUAG</td></tr>
<tr><td>Bought 3.0 @ &pound;61.25 per share</td></tr>
<tr><td>Total: &pound;183.75</td></tr>
</table>
</td>
</tr>
<tr>
<td>
<table>
<tr><td>Some broken order with no ticker and no bought line</td></tr>
<tr><td>(Malformed — IE dropped a row mid-render)</td></tr>
</table>
</td>
</tr>
</table>
</body></html>
------=_Part_PM--

View file

@ -0,0 +1,55 @@
From: InvestEngine <no-reply@investengine.com>
To: viktorbarzin@example.com
Subject: Your portfolio has been updated
Date: Wed, 01 Apr 2026 09:15:00 +0000
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="----=_Part_1"
------=_Part_1
Content-Type: text/plain; charset=UTF-8
(HTML-only view — your client does not render HTML emails.)
------=_Part_1
Content-Type: text/html; charset=UTF-8
<html><head><title>InvestEngine</title></head><body>
<table><tr><td>Header logo</td></tr></table>
<table>
<tr><td>Client name: Redacted</td></tr>
<tr><td>Trading venue: London Stock Exchange</td></tr>
<tr><td>Type: Market Order(s)</td></tr>
<tr><td>Here's a summary of the trades we've made for you</td></tr>
<tr>
<td>a</td><td>b</td><td>c</td><td>d</td>
<td> Date: 01 April 2026 </td>
</tr>
<tr><td>filler</td></tr>
<tr><td>filler</td></tr>
<tr><td>filler</td></tr>
<tr><td>filler</td></tr>
<tr><td>filler</td></tr>
<tr>
<td>
<table>
<tr><td>Vanguard S&amp;P 500: VUAG</td></tr>
<tr><td>Bought 10.5 @ &pound;62.10 per share</td></tr>
<tr><td>Total: &pound;652.05</td></tr>
<tr><td>ISIN: IE00BFMXXD54, Order ID: 300000/4000001, Traded at 9:05am GMT</td></tr>
</table>
</td>
</tr>
<tr>
<td>
<table>
<tr><td>iShares Core MSCI World: SWDA</td></tr>
<tr><td>Bought 2.25 @ &pound;85.40 per share</td></tr>
<tr><td>Total: &pound;192.15</td></tr>
<tr><td>ISIN: IE00B4L5Y983, Order ID: 300000/4000002, Traded at 9:06am GMT</td></tr>
</table>
</td>
</tr>
</table>
</body></html>
------=_Part_1--

View file

@ -0,0 +1,15 @@
From: InvestEngine <no-reply@investengine.com>
To: viktorbarzin@example.com
Subject: InvestEngine newsletter
Date: Thu, 10 Apr 2025 12:00:00 +0000
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Hi Viktor,
This is a newsletter, not a trade confirmation. There is no structured
order data here — just marketing copy and a promo for a new feature we
are rolling out. Thanks for being a customer.
Cheers,
The InvestEngine team

View file

@ -42,3 +42,67 @@ def test_rfc2822_notes_record_parse_strategy() -> None:
a = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0] a = parse_invest_engine_email(_load("rfc2822_v2_single_buy.eml"))[0]
assert a.notes is not None assert a.notes is not None
assert "rfc2822" in a.notes assert "rfc2822" in a.notes
# -- HTML table body (multipart/alternative, two orders) --
def test_html_body_parses_both_orders() -> None:
activities = parse_invest_engine_email(_load("html_two_orders.eml"))
assert len(activities) == 2
a, b = activities
assert a.symbol == "VUAG"
assert a.quantity == Decimal("10.5")
assert a.unit_price == Decimal("62.10")
assert a.date == datetime(2026, 4, 1)
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
assert a.activity_type is ActivityType.BUY
assert b.symbol == "SWDA"
assert b.quantity == Decimal("2.25")
assert b.unit_price == Decimal("85.40")
assert b.date == datetime(2026, 4, 1)
def test_html_notes_record_html_strategy() -> None:
a = parse_invest_engine_email(_load("html_two_orders.eml"))[0]
assert a.notes is not None
assert "html" in a.notes
# -- CSV attachment body --
def test_csv_attachment_parses_all_rows() -> None:
activities = parse_invest_engine_email(_load("csv_attachment.eml"))
assert len(activities) == 3
by_symbol = {a.symbol: a for a in activities}
assert by_symbol["VUAG"].quantity == Decimal("12.5")
assert by_symbol["VUAG"].unit_price == Decimal("63.21")
assert by_symbol["VUAG"].date == datetime(2025, 4, 2)
assert by_symbol["SWDA"].quantity == Decimal("4.75")
assert by_symbol["VUSA"].date == datetime(2025, 4, 4)
for a in activities:
assert a.activity_type is ActivityType.BUY
assert a.currency == "GBP"
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
assert a.notes is not None
assert "csv" in a.notes
# -- graceful failure modes --
def test_unparseable_email_returns_empty_list() -> None:
assert parse_invest_engine_email(_load("unparseable.eml")) == []
def test_html_partial_match_returns_only_parseable_orders() -> None:
activities = parse_invest_engine_email(_load("html_partial_match.eml"))
assert len(activities) == 1
a = activities[0]
assert a.symbol == "VUAG"
assert a.quantity == Decimal("3.0")
assert a.unit_price == Decimal("61.25")
assert a.date == datetime(2026, 4, 15)

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from decimal import Decimal
from broker_sync.models import AccountType, ActivityType
from broker_sync.providers.parsers.schwab import parse_schwab_email
_SELL = """
<html><body>
<table>
<tr><td>Date</td><td class="dark-background-body" align="right">Jan 23, 2025</td></tr>
<tr><td>Action</td><td class="dark-background-body" align="right">Sold</td></tr>
<tr><td>Quantity</td><td class="dark-background-body" align="right">100.0</td></tr>
<tr><td>Ticker</td><td class="dark-background-body" align="right">META</td></tr>
<tr><td>Price</td><td class="dark-background-body" align="right">$612.34</td></tr>
</table>
</body></html>
"""
_BUY = """
<html><body><table>
<tr><td class="dark-background-body" align="right">2024-11-15</td></tr>
<tr><td class="dark-background-body" align="right">Bought</td></tr>
<tr><td class="dark-background-body" align="right">5.5</td></tr>
<tr><td class="dark-background-body" align="right">AAPL</td></tr>
<tr><td class="dark-background-body" align="right">$225.00</td></tr>
</table></body></html>
"""
_MALFORMED = "<html><body>no transaction here</body></html>"
_MISSING_CELLS = """
<html><body><table>
<tr><td class="dark-background-body" align="right">Jan 23, 2025</td></tr>
<tr><td class="dark-background-body" align="right">Sold</td></tr>
</table></body></html>
"""
def test_sell_email_parses_to_one_sell_activity() -> None:
acts = parse_schwab_email(_SELL)
assert len(acts) == 1
a = acts[0]
assert a.activity_type is ActivityType.SELL
assert a.symbol == "META"
assert a.quantity == Decimal("100.0")
assert a.unit_price == Decimal("612.34")
assert a.currency == "USD"
assert a.account_id == "schwab-workplace"
assert a.account_type is AccountType.GIA
assert a.date.date().isoformat() == "2025-01-23"
def test_buy_email_becomes_buy_activity() -> None:
acts = parse_schwab_email(_BUY)
assert len(acts) == 1
a = acts[0]
assert a.activity_type is ActivityType.BUY
assert a.symbol == "AAPL"
assert a.quantity == Decimal("5.5")
assert a.unit_price == Decimal("225.00")
def test_malformed_email_returns_empty_list() -> None:
# No matching td cells at all.
assert parse_schwab_email(_MALFORMED) == []
def test_missing_cells_returns_empty_list() -> None:
# Only 2 of the 5 required cells — parser must bail cleanly.
assert parse_schwab_email(_MISSING_CELLS) == []
def test_external_id_is_stable_across_reruns() -> None:
# Same email → same external_id (deterministic, not timestamp-based).
a1 = parse_schwab_email(_SELL)[0]
a2 = parse_schwab_email(_SELL)[0]
assert a1.external_id == a2.external_id
def test_price_with_commas_parses() -> None:
html = _SELL.replace("$612.34", "$1,612.34")
# The first activity is the inferred BUY (date 2025-01-23 ≥ 2026-04-01? no →
# only one activity for this old-dated email), so index 0 is the SELL.
acts = parse_schwab_email(html)
sell = next(a for a in acts if a.activity_type is ActivityType.SELL)
assert sell.unit_price == Decimal("1612.34")
# --- Inferred vest BUY ---------------------------------------------------
def _recent_sell(date_iso: str = "2026-05-19", qty: str = "55", price: str = "609.35") -> str:
return f"""
<html><body><table>
<tr><td class="dark-background-body" align="right">{date_iso}</td></tr>
<tr><td class="dark-background-body" align="right">Sold</td></tr>
<tr><td class="dark-background-body" align="right">{qty}</td></tr>
<tr><td class="dark-background-body" align="right">META</td></tr>
<tr><td class="dark-background-body" align="right">${price}</td></tr>
</table></body></html>
"""
def test_recent_sell_emits_paired_buy() -> None:
"""SELL dated on/after the synthesis boundary triggers a paired BUY."""
acts = parse_schwab_email(_recent_sell())
assert len(acts) == 2
buy = next(a for a in acts if a.activity_type is ActivityType.BUY)
sell = next(a for a in acts if a.activity_type is ActivityType.SELL)
assert buy.quantity == sell.quantity == Decimal("55")
assert buy.unit_price == sell.unit_price == Decimal("609.35")
assert buy.date == sell.date
assert buy.symbol == sell.symbol == "META"
assert "schwab-vest-inferred-from-same-day-sell" in (buy.notes or "")
assert buy.external_id == "schwab:vest:2026-05-19:META:BUY:55"
assert sell.external_id == "schwab:2026-05-19:META:SELL:55"
def test_old_sell_emits_only_sell() -> None:
"""SELL dated before 2026-04-01 (default boundary) skips the paired BUY —
those vests already have csv-sourced BUY rows in Wealthfolio."""
acts = parse_schwab_email(_recent_sell(date_iso="2025-08-19"))
assert len(acts) == 1
assert acts[0].activity_type is ActivityType.SELL
def test_boundary_env_var_overrides(monkeypatch: object) -> None:
"""The synthesis boundary is configurable via env var."""
import os
os.environ["SCHWAB_VEST_INFER_FROM_DATE"] = "2025-01-01"
try:
acts = parse_schwab_email(_recent_sell(date_iso="2025-08-19"))
assert len(acts) == 2 # now in scope
finally:
del os.environ["SCHWAB_VEST_INFER_FROM_DATE"]
def test_buy_email_does_not_emit_inferred_buy() -> None:
"""BUY-direction emails (rare for workplace account) don't get paired."""
acts = parse_schwab_email(_BUY.replace("2024-11-15", "2026-05-15"))
assert len(acts) == 1
assert acts[0].activity_type is ActivityType.BUY

View file

@ -0,0 +1,209 @@
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 (
FidelityHolding,
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[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

View file

@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from broker_sync.models import AccountType, ActivityType
from broker_sync.providers.finance_mysql import _normalise_symbol, _route, _row_to_activity
def test_lse_ticker_routes_to_investengine() -> None:
acct, t, ccy = _route("VUAG.L")
assert acct == "invest-engine-primary"
assert t is AccountType.ISA
assert ccy == "GBP"
def test_us_ticker_routes_to_schwab() -> None:
assert _route("META") == ("schwab-workplace", AccountType.GIA, "USD")
assert _route("FLME_US_EQ") == ("schwab-workplace", AccountType.GIA, "USD")
def test_normalise_symbol() -> None:
assert _normalise_symbol("VUAG.L") == "VUAG"
assert _normalise_symbol("VUSA.L") == "VUSA"
assert _normalise_symbol("META") == "META"
assert _normalise_symbol("FLME_US_EQ") == "FLME"
assert _normalise_symbol("FOO_EQ") == "FOO"
def test_row_to_buy_activity() -> None:
row = {
"id": "123456",
"ticker": "VUAG.L",
"buy_price": 85.5,
"num_shares": 10.0,
"currency": "GBP",
"buy_date": datetime(2022, 3, 15, 10, 30),
"account_id": 1,
}
a = _row_to_activity(row)
assert a.external_id == "finance-mysql:position:123456"
assert a.account_id == "invest-engine-primary"
assert a.account_type is AccountType.ISA
assert a.activity_type is ActivityType.BUY
assert a.symbol == "VUAG" # .L stripped
assert a.quantity == Decimal("10.0")
assert a.unit_price == Decimal("85.5")
assert a.currency == "GBP"
assert a.date == datetime(2022, 3, 15, 10, 30, tzinfo=UTC)
def test_row_to_sell_when_qty_negative() -> None:
row = {
"id": "x",
"ticker": "META",
"buy_price": 450.0,
"num_shares": -2.5, # sell
"currency": "USD",
"buy_date": datetime(2024, 8, 5),
"account_id": 1,
}
a = _row_to_activity(row)
assert a.activity_type is ActivityType.SELL
assert a.quantity == Decimal("2.5") # absolute
assert a.account_id == "schwab-workplace"
assert a.symbol == "META"

View file

@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import pytest
from broker_sync.models import ActivityType
from broker_sync.providers.ibkr import (
IBKRAccountMismatchError,
IBKRProvider,
_map_cash_to_activity,
_map_trade_to_activity,
canonical_symbol,
)
# -- canonical_symbol --
def test_canonical_symbol_lse_etf_gets_l_suffix() -> None:
assert canonical_symbol("VUAG", exchange="LSEETF", currency="GBP") == "VUAG.L"
def test_canonical_symbol_us_stock_unchanged() -> None:
assert canonical_symbol("AAPL", exchange="NASDAQ", currency="USD") == "AAPL"
def test_canonical_symbol_lse_gbp_inferred_when_exchange_missing() -> None:
"""IBKR Flex sometimes omits exchange — infer LSE from currency==GBP."""
assert canonical_symbol("VUAG", exchange=None, currency="GBP") == "VUAG.L"
def test_canonical_symbol_already_suffixed_unchanged() -> None:
assert canonical_symbol("VUAG.L", exchange="LSEETF", currency="GBP") == "VUAG.L"
# -- Trade mapping --
def test_map_trade_buy_to_activity() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[0] # T1001: 10 VUAG BUY @ 107.50 GBP, comm -1.05
activity = _map_trade_to_activity(trade, account_id="wf-acct-uuid")
assert activity.external_id == "ibkr:trade:T1001"
assert activity.account_id == "wf-acct-uuid"
assert activity.activity_type == ActivityType.BUY
assert activity.symbol == "VUAG.L"
assert activity.quantity == Decimal("10")
assert activity.unit_price == Decimal("107.50")
assert activity.fee == Decimal("1.05")
assert activity.currency == "GBP"
assert isinstance(activity.date, datetime)
assert activity.date.tzinfo is not None
def test_map_trade_sell_to_activity() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[2] # T1003: 2 VUAG SELL @ 108.00 GBP
activity = _map_trade_to_activity(trade, account_id="wf-acct")
assert activity.activity_type == ActivityType.SELL
assert activity.symbol == "VUAG.L"
assert activity.quantity == Decimal("2")
assert activity.unit_price == Decimal("108.00")
def test_map_trade_us_stock_keeps_usd_currency_and_no_suffix() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
trade = r.FlexStatements[0].Trades[1] # T1002: AAPL BUY USD
activity = _map_trade_to_activity(trade, account_id="wf-acct")
assert activity.symbol == "AAPL"
assert activity.currency == "USD"
# -- Cash mapping --
def test_map_cash_dividend_to_activity() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
cash = r.FlexStatements[0].CashTransactions[0] # C5001: Dividends 3.50 GBP
activity = _map_cash_to_activity(cash, account_id="wf-acct")
assert activity is not None
assert activity.external_id == "ibkr:cash:C5001"
assert activity.activity_type == ActivityType.DIVIDEND
assert activity.amount == Decimal("3.50")
assert activity.currency == "GBP"
def test_map_cash_withholding_tax_to_tax_activity() -> None:
from ibflex import parser
r = parser.parse("tests/fixtures/ibkr/sample_flex.xml")
cash = r.FlexStatements[0].CashTransactions[1] # C5002: Withholding Tax -0.35 GBP
activity = _map_cash_to_activity(cash, account_id="wf-acct")
assert activity is not None
assert activity.activity_type == ActivityType.TAX
assert activity.amount == Decimal("0.35") # always positive on Activity
def test_map_cash_unknown_type_returns_none_and_logs(caplog: pytest.LogCaptureFixture) -> None:
"""Unknown CashTransaction.type produces None + a WARNING log line."""
class FakeType:
name = "FrobnicatedThing"
class FakeCash:
transactionID = "C9999"
dateTime = None
type = FakeType()
amount = Decimal("0")
currency = "GBP"
with caplog.at_level("WARNING"):
result = _map_cash_to_activity(FakeCash, account_id="wf-acct")
assert result is None
assert any("FROBNICATEDTHING" in r.message for r in caplog.records)
# -- IBKRProvider end-to-end --
async def test_ibkr_provider_fetch_returns_mapped_activities(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IBKRProvider.fetch() yields all mapped activities (trades + cash)."""
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
upstream_account_id="U12345678",
)
activities = [a async for a in provider.fetch()]
# 3 trades + 2 cash = 5
assert len(activities) == 5
types = sorted(a.activity_type.name for a in activities)
assert types == ["BUY", "BUY", "DIVIDEND", "SELL", "TAX"]
async def test_ibkr_provider_account_mismatch_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Mismatched accountId raises and writes nothing."""
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
upstream_account_id="U99999999", # WRONG
)
with pytest.raises(IBKRAccountMismatchError, match="U12345678"):
_ = [a async for a in provider.fetch()]
async def test_ibkr_provider_open_positions_after_fetch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""open_positions() returns canonicalised symbol + qty after fetch drained."""
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
upstream_account_id="U12345678",
)
# drain the iterator before reading positions
[a async for a in provider.fetch()]
positions = provider.open_positions()
# VUAG → VUAG.L (LSE inferred from GBP); AAPL unchanged (USD)
assert dict(positions) == {"VUAG.L": Decimal("8"), "AAPL": Decimal("5")}
async def test_ibkr_provider_cash_balances_after_fetch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""cash_balances() returns (currency, ending_cash) tuples from CashReport."""
from ibflex import client as ib_client
with open("tests/fixtures/ibkr/sample_flex.xml", "rb") as f:
xml_bytes = f.read()
monkeypatch.setattr(ib_client, "download", lambda *a, **kw: xml_bytes)
provider = IBKRProvider(
token="t",
query_id="q",
upstream_account_id="U12345678",
)
[a async for a in provider.fetch()]
balances = provider.cash_balances()
# Fixture has BASE_SUMMARY + USD rows, both 1.23
assert dict(balances) == {"BASE_SUMMARY": Decimal("1.23"), "USD": Decimal("1.23")}
def test_ibkr_provider_cash_balances_before_fetch_returns_empty() -> None:
"""No CashReport data before fetch()."""
provider = IBKRProvider(token="t", query_id="q", upstream_account_id="U12345678")
assert provider.cash_balances() == []

View file

@ -0,0 +1,206 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from broker_sync.models import AccountType, Activity, ActivityType
if TYPE_CHECKING:
from pytest import MonkeyPatch
from broker_sync.providers.imap import (
_IE_GIA_ACCOUNT_ID,
_IE_ISA_ACCOUNT_ID,
_split_ie_by_isa_cap,
_uk_tax_year_start,
)
def _buy(on: datetime, qty: str, price: str) -> Activity:
return Activity(
external_id=f"invest-engine:{on.isoformat()}|{qty}|{price}",
account_id=_IE_ISA_ACCOUNT_ID,
account_type=AccountType.ISA,
date=on,
activity_type=ActivityType.BUY,
currency="GBP",
symbol="VUAG",
quantity=Decimal(qty),
unit_price=Decimal(price),
)
def test_uk_tax_year_start_before_april_6_rolls_back() -> None:
assert _uk_tax_year_start(datetime(2025, 4, 5, tzinfo=UTC)) == date(2024, 4, 6)
assert _uk_tax_year_start(datetime(2025, 4, 6, tzinfo=UTC)) == date(2025, 4, 6)
assert _uk_tax_year_start(datetime(2025, 1, 15, tzinfo=UTC)) == date(2024, 4, 6)
assert _uk_tax_year_start(datetime(2024, 4, 7, tzinfo=UTC)) == date(2024, 4, 6)
def test_single_tax_year_under_cap_stays_isa() -> None:
acts = [
_buy(datetime(2024, 5, 1, tzinfo=UTC), "100", "50"), # £5000
_buy(datetime(2024, 8, 1, tzinfo=UTC), "100", "80"), # £8000
]
routed = _split_ie_by_isa_cap(acts)
assert all(a.account_id == _IE_ISA_ACCOUNT_ID for a in routed)
assert all(a.account_type is AccountType.ISA for a in routed)
def test_overflow_past_cap_flips_to_gia() -> None:
acts = [
_buy(datetime(2024, 5, 1, tzinfo=UTC), "100", "80"), # £8,000
# +£12,000 → £20,000 total; prev £8k < cap → ISA
_buy(datetime(2024, 6, 1, tzinfo=UTC), "150", "80"),
_buy(datetime(2024, 7, 1, tzinfo=UTC), "10", "80"), # prev £20,000 ≥ cap → GIA
_buy(datetime(2024, 8, 1, tzinfo=UTC), "10", "80"), # GIA
]
routed = _split_ie_by_isa_cap(acts)
assert routed[0].account_id == _IE_ISA_ACCOUNT_ID
assert routed[1].account_id == _IE_ISA_ACCOUNT_ID
assert routed[2].account_id == _IE_GIA_ACCOUNT_ID
assert routed[2].account_type is AccountType.GIA
assert routed[3].account_id == _IE_GIA_ACCOUNT_ID
def test_tax_year_boundary_resets_cap() -> None:
acts = [
# 2023-24 tax year: £20k in ISA, plus one in GIA
_buy(datetime(2023, 5, 1, tzinfo=UTC), "400", "50"), # £20,000 → ISA (prev 0 < cap)
_buy(datetime(2024, 1, 1, tzinfo=UTC), "100", "50"), # GIA (prev 20k)
# 2024-25 tax year starts 2024-04-06 — cap resets
_buy(datetime(2024, 5, 1, tzinfo=UTC), "100", "50"), # ISA (prev 0 for new year)
]
routed = _split_ie_by_isa_cap(acts)
assert routed[0].account_id == _IE_ISA_ACCOUNT_ID
assert routed[1].account_id == _IE_GIA_ACCOUNT_ID
assert routed[2].account_id == _IE_ISA_ACCOUNT_ID
def test_out_of_order_activities_sorted_before_cap_applied() -> None:
acts = [
_buy(datetime(2024, 8, 1, tzinfo=UTC), "10", "80"), # later date but given first
_buy(datetime(2024, 5, 1, tzinfo=UTC), "250", "80"), # earlier, £20,000 → ISA
]
routed = _split_ie_by_isa_cap(acts)
by_date = {a.date: a for a in routed}
assert by_date[datetime(2024, 5, 1, tzinfo=UTC)].account_id == _IE_ISA_ACCOUNT_ID
assert by_date[datetime(2024, 8, 1, tzinfo=UTC)].account_id == _IE_GIA_ACCOUNT_ID
def test_non_ie_activities_passed_through_unchanged() -> None:
schwab_act = Activity(
external_id="schwab:abc",
account_id="schwab-workplace",
account_type=AccountType.GIA,
date=datetime(2024, 5, 1, tzinfo=UTC),
activity_type=ActivityType.SELL,
currency="USD",
symbol="META",
quantity=Decimal("10"),
unit_price=Decimal("500"),
)
routed = _split_ie_by_isa_cap([schwab_act])
assert routed[0].account_id == "schwab-workplace"
assert routed[0].account_type is AccountType.GIA
def test_invest_engine_skipped_by_default(monkeypatch: MonkeyPatch) -> None:
"""InvestEngine messages MUST be skipped by default, even with no env set.
Post-mortem 2026-05-27: any code path that doesn't set the cron's env
(e.g. `kubectl run --rm` or devvm `poetry run`) was re-importing IE
BUYs through this IMAP path. The opt-out env var was a foot-gun.
Invariant now: structural default skip; opt back in only with
BROKER_SYNC_IMAP_INCLUDE_PROVIDERS.
"""
from broker_sync.providers import imap as imap_mod
from broker_sync.providers.parsers import invest_engine as ie_parser
ie_email = (
b"From: noreply@investengine.com\r\n"
b"Subject: VUAG Bought\r\n"
b"Content-Type: text/plain\r\n\r\n"
b"Vanguard S&P 500: VUAG Bought 10.0 @ 100.0 per share Total: 1000.00\r\n"
)
schwab_email = (
b"From: donotreply@schwab.com\r\n"
b"Subject: Order Confirmed\r\n"
b"Content-Type: text/html\r\n\r\n"
b"<html><body>no-op</body></html>\r\n"
)
monkeypatch.setattr(imap_mod, "_fetch_all", lambda _: [ie_email, schwab_email])
monkeypatch.setattr(ie_parser, "parse_invest_engine_email", lambda raw: [object()])
monkeypatch.setattr(imap_mod, "parse_schwab_email", lambda html: [object()])
creds = imap_mod.ImapCreds(host="h", user="u", password="p", directory="d")
# Default (no env): IE skipped, Schwab parsed.
monkeypatch.delenv("BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS", raising=False)
monkeypatch.delenv("BROKER_SYNC_IMAP_INCLUDE_PROVIDERS", raising=False)
out_default = imap_mod.fetch_activities(creds)
assert len(out_default) == 1, "IE must be skipped by default; only Schwab emitted"
def test_invest_engine_opt_in_via_include_env(monkeypatch: MonkeyPatch) -> None:
"""Setting BROKER_SYNC_IMAP_INCLUDE_PROVIDERS=invest-engine re-enables
IE parsing (escape hatch for the legacy IMAP path)."""
from broker_sync.providers import imap as imap_mod
from broker_sync.providers.parsers import invest_engine as ie_parser
ie_email = b"From: noreply@investengine.com\r\n\r\nirrelevant\r\n"
schwab_email = b"From: donotreply@schwab.com\r\n\r\n<html></html>\r\n"
monkeypatch.setattr(imap_mod, "_fetch_all", lambda _: [ie_email, schwab_email])
monkeypatch.setattr(ie_parser, "parse_invest_engine_email", lambda raw: [object()])
monkeypatch.setattr(imap_mod, "parse_schwab_email", lambda html: [object()])
creds = imap_mod.ImapCreds(host="h", user="u", password="p", directory="d")
monkeypatch.setenv("BROKER_SYNC_IMAP_INCLUDE_PROVIDERS", "invest-engine")
monkeypatch.delenv("BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS", raising=False)
out = imap_mod.fetch_activities(creds)
assert len(out) == 2, "INCLUDE=invest-engine must re-enable IE parsing"
def test_exclude_schwab_still_works(monkeypatch: MonkeyPatch) -> None:
"""EXCLUDE env still works for other providers (forward-compat)."""
from broker_sync.providers import imap as imap_mod
from broker_sync.providers.parsers import invest_engine as ie_parser
schwab_email = b"From: donotreply@schwab.com\r\n\r\n<html></html>\r\n"
monkeypatch.setattr(imap_mod, "_fetch_all", lambda _: [schwab_email])
monkeypatch.setattr(ie_parser, "parse_invest_engine_email", lambda raw: [object()])
monkeypatch.setattr(imap_mod, "parse_schwab_email", lambda html: [object()])
creds = imap_mod.ImapCreds(host="h", user="u", password="p", directory="d")
monkeypatch.setenv("BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS", "schwab")
monkeypatch.delenv("BROKER_SYNC_IMAP_INCLUDE_PROVIDERS", raising=False)
out = imap_mod.fetch_activities(creds)
assert len(out) == 0, "Schwab must be skipped when in EXCLUDE list"
def test_include_overrides_default_and_exclude(monkeypatch: MonkeyPatch) -> None:
"""INCLUDE wins over both the structural default and EXCLUDE env var."""
from broker_sync.providers import imap as imap_mod
monkeypatch.setenv("BROKER_SYNC_IMAP_EXCLUDE_PROVIDERS", "invest-engine,schwab")
monkeypatch.setenv("BROKER_SYNC_IMAP_INCLUDE_PROVIDERS", "invest-engine")
resolved = imap_mod._resolve_excluded_providers()
assert "invest-engine" not in resolved
assert "schwab" in resolved
def test_schwab_subdomain_sender_matches() -> None:
"""Real Schwab trade emails come from `donotreply@mail.schwab.com`
(subdomain), not just `donotreply@schwab.com`. The matcher must
accept either form."""
from broker_sync.providers.imap import _SCHWAB_SENDERS
# Verify the static set works
assert "donotreply@schwab.com" in _SCHWAB_SENDERS
# Verify the subdomain suffix check
for addr in (
"donotreply@mail.schwab.com",
"wealthnotify@equityawards.schwab.com",
):
assert addr.endswith(".schwab.com"), addr

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -12,6 +12,9 @@ import pytest
from broker_sync.models import Account, AccountType, Activity, ActivityType from broker_sync.models import Account, AccountType, Activity, ActivityType
from broker_sync.sinks.wealthfolio import ( from broker_sync.sinks.wealthfolio import (
ImportValidationError, ImportValidationError,
ManualSnapshotPayload,
SnapshotPosition,
WealthfolioError,
WealthfolioSink, WealthfolioSink,
WealthfolioUnauthorizedError, WealthfolioUnauthorizedError,
) )
@ -48,7 +51,10 @@ def _login_ok(req: httpx.Request) -> httpx.Response:
assert body == {"password": "hunter2"} assert body == {"password": "hunter2"}
return httpx.Response( return httpx.Response(
200, 200,
json={"authenticated": True, "expiresIn": 604800}, json={
"authenticated": True,
"expiresIn": 604800
},
headers={"set-cookie": "wf_token=abc123; Path=/api; HttpOnly"}, headers={"set-cookie": "wf_token=abc123; Path=/api; HttpOnly"},
) )
@ -219,7 +225,8 @@ async def test_import_dry_run_then_real(tmp_path: Path) -> None:
calls.append(req.url.path) calls.append(req.url.path)
if req.url.path == "/api/v1/activities/import/check": if req.url.path == "/api/v1/activities/import/check":
# /import/check hydrates and returns a list of ActivityImport. # /import/check hydrates and returns a list of ActivityImport.
return httpx.Response(200, json=[ return httpx.Response(200,
json=[
{ {
"symbol": "VUAG", "symbol": "VUAG",
"isValid": True, "isValid": True,
@ -233,7 +240,10 @@ async def test_import_dry_run_then_real(tmp_path: Path) -> None:
200, 200,
json={ json={
"activities": [ "activities": [
{"id": "wf-1", "external_id": "t212:1"}, {
"id": "wf-1",
"external_id": "t212:1"
},
], ],
}, },
) )
@ -267,3 +277,158 @@ async def test_import_halts_on_validation_failure(tmp_path: Path) -> None:
with pytest.raises(ImportValidationError, match="unknown symbol"): with pytest.raises(ImportValidationError, match="unknown symbol"):
await sink.import_activities([_buy()]) await sink.import_activities([_buy()])
assert calls == ["/api/v1/activities/import/check"] # real import never hit 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
# -- compute_position_qty (used by IBKR reconciliation) --
@pytest.mark.asyncio
async def test_compute_position_qty_sums_buys_minus_sells(tmp_path: Path) -> None:
"""Sums BUY/ADD_HOLDING/TRANSFER_IN minus SELL/REMOVE_HOLDING/TRANSFER_OUT
quantities per symbol, skipping cash activities."""
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
page_1: dict[str, Any] = {
"activities": [
{"symbol": "VUAG.L", "activityType": "BUY", "quantity": "10"},
{"symbol": "VUAG.L", "activityType": "SELL", "quantity": "2"},
{"symbol": "AAPL", "activityType": "BUY", "quantity": "5"},
{"symbol": "$CASH-GBP", "activityType": "DEPOSIT", "quantity": "0",
"amount": "100"},
# Unknown activity type — must be skipped, not crash.
{"symbol": "VUAG.L", "activityType": "DIVIDEND", "quantity": "0",
"amount": "0.5"},
],
"totalPages": 1,
}
async def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/v1/activities/search":
return httpx.Response(200, json=page_1)
raise AssertionError(f"unexpected request: {req.method} {req.url.path}")
sink = _client(httpx.MockTransport(handler), sp)
result = await sink.compute_position_qty("acct-123")
assert result == {"VUAG.L": Decimal("8"), "AAPL": Decimal("5")}
@pytest.mark.asyncio
async def test_compute_position_qty_paginates(tmp_path: Path) -> None:
"""Walks all pages until totalPages reached."""
sp = tmp_path / "s.json"
sp.write_text(json.dumps({"cookies": {"wf_token": "fresh"}}))
pages: dict[int, dict[str, Any]] = {
1: {"activities": [{"symbol": "VUAG.L", "activityType": "BUY",
"quantity": "3"}], "totalPages": 2},
2: {"activities": [{"symbol": "VUAG.L", "activityType": "BUY",
"quantity": "4"}], "totalPages": 2},
}
seen_pages: list[int] = []
async def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
seen_pages.append(body["page"])
return httpx.Response(200, json=pages[body["page"]])
sink = _client(httpx.MockTransport(handler), sp)
result = await sink.compute_position_qty("acct-x")
assert sorted(seen_pages) == [1, 2]
assert result == {"VUAG.L": Decimal("7")}

66
tests/test_metrics.py Normal file
View file

@ -0,0 +1,66 @@
from __future__ import annotations
import httpx
import pytest
from broker_sync.metrics import push_pushgateway
async def test_push_pushgateway_posts_text_format() -> None:
captured: dict[str, str] = {}
def transport_handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["method"] = request.method
captured["body"] = request.content.decode("utf-8")
return httpx.Response(200)
transport = httpx.MockTransport(transport_handler)
await push_pushgateway(
job="broker-sync-ibkr",
metrics=[
("ibkr_position_drift_shares", {"symbol": "VUAG.L"}, 0.0),
("ibkr_sync_last_success_timestamp_seconds", {}, 1779830000.0),
],
pushgateway_url="http://pg.example/metrics",
transport=transport,
)
assert captured["method"] == "POST"
assert captured["url"] == "http://pg.example/metrics/job/broker-sync-ibkr"
body = captured["body"]
assert 'ibkr_position_drift_shares{symbol="VUAG.L"} 0.0' in body
assert "ibkr_sync_last_success_timestamp_seconds 1779830000.0" in body
async def test_push_pushgateway_raises_on_non_2xx() -> None:
transport = httpx.MockTransport(lambda r: httpx.Response(500, text="boom"))
with pytest.raises(RuntimeError, match="pushgateway.*500"):
await push_pushgateway(
job="x",
metrics=[("m", {}, 1.0)],
pushgateway_url="http://pg/metrics",
transport=transport,
)
async def test_push_pushgateway_uses_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200)
transport = httpx.MockTransport(handler)
monkeypatch.setenv("PUSHGATEWAY_URL", "http://from-env/metrics")
await push_pushgateway(
job="j",
metrics=[("m", {}, 1.0)],
transport=transport,
)
assert captured["url"] == "http://from-env/metrics/job/j"
async def test_push_pushgateway_raises_when_url_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PUSHGATEWAY_URL", raising=False)
with pytest.raises(RuntimeError, match="PUSHGATEWAY_URL not set"):
await push_pushgateway(job="j", metrics=[("m", {}, 1.0)])

View file

@ -86,18 +86,22 @@ async def test_pipeline_skips_dedup_then_imports_new(tmp_path: Path) -> None:
body = json.loads(req.content) body = json.loads(req.content)
# Echo each activity back marked valid (mimic Wealthfolio's # Echo each activity back marked valid (mimic Wealthfolio's
# hydrate step). # hydrate step).
return httpx.Response(200, json=[ return httpx.Response(200,
{**a, "isValid": True, "errors": None} for a in body["activities"] json=[{
]) **a, "isValid": True,
"errors": None
} for a in body["activities"]])
if req.url.path == "/api/v1/activities/import": if req.url.path == "/api/v1/activities/import":
body = req.content.decode() body = req.content.decode()
posted_batches.append(body) posted_batches.append(body)
return httpx.Response( return httpx.Response(
200, 200,
json={"activities": [ json={
{"id": f"wf-{i}", "external_id": ext} "activities": [{
for i, ext in enumerate(["a", "b", "c"]) "id": f"wf-{i}",
]}, "external_id": ext
} for i, ext in enumerate(["a", "b", "c"])]
},
) )
return httpx.Response(500) return httpx.Response(500)
@ -115,21 +119,31 @@ async def test_pipeline_skips_dedup_then_imports_new(tmp_path: Path) -> None:
finally: finally:
await sink.close() await sink.close()
# 3 provider activities fetched, but pipeline expands each BUY into
# (BUY, matching DEPOSIT). "a" is already-seen → skipped; its match
# "cash-flow-match:buy:a" is NEW since it wasn't seeded.
assert result.fetched == 3 assert result.fetched == 3
assert result.new_after_dedup == 2 assert result.new_after_dedup == 5
assert result.imported == 2 assert result.imported == 5
assert result.failed == 0 assert result.failed == 0
assert len(posted_batches) == 1 assert len(posted_batches) == 1
body = posted_batches[0] body = posted_batches[0]
# Only the new rows (b, c) — NOT the already-seen "a". # Only the new rows (b, c + the 3 matches) — NOT the already-seen "a".
assert "sync:fake:a" not in body assert "sync:fake:a" not in body
assert "sync:fake:b" in body assert "sync:fake:b" in body
assert "sync:fake:c" in body assert "sync:fake:c" in body
# Matching DEPOSITs rode along with their trade.
assert "cash-flow-match:buy:a" in body
assert "cash-flow-match:buy:b" in body
assert "cash-flow-match:buy:c" in body
# All three external_ids are now in dedup after the run. # All six external_ids are now in dedup after the run.
assert dedup.has_seen("fake", "fake-isa", "a") assert dedup.has_seen("fake", "fake-isa", "a")
assert dedup.has_seen("fake", "fake-isa", "b") assert dedup.has_seen("fake", "fake-isa", "b")
assert dedup.has_seen("fake", "fake-isa", "c") assert dedup.has_seen("fake", "fake-isa", "c")
assert dedup.has_seen("fake", "fake-isa", "cash-flow-match:buy:a")
assert dedup.has_seen("fake", "fake-isa", "cash-flow-match:buy:b")
assert dedup.has_seen("fake", "fake-isa", "cash-flow-match:buy:c")
async def test_pipeline_records_failure_when_import_rejects(tmp_path: Path) -> None: async def test_pipeline_records_failure_when_import_rejects(tmp_path: Path) -> None:
@ -168,8 +182,86 @@ async def test_pipeline_records_failure_when_import_rejects(tmp_path: Path) -> N
finally: finally:
await sink.close() await sink.close()
# Pipeline expands 1 BUY into (BUY, matching DEPOSIT). Both are in the
# batch that /import/check rejects, so both are counted as failed.
assert result.fetched == 1 assert result.fetched == 1
assert result.imported == 0 assert result.imported == 0
assert result.failed == 1 assert result.failed == 2
# NOT recorded in dedup so the next run retries. # NOT recorded in dedup so the next run retries both.
assert not dedup.has_seen("fake", "fake-isa", "a") assert not dedup.has_seen("fake", "fake-isa", "a")
assert not dedup.has_seen("fake", "fake-isa", "cash-flow-match:buy:a")
# -- Cash-flow match helpers ---------------------------------------------
from broker_sync.pipeline import _matched_cash_flow, _with_cash_flow_match # noqa: E402
def _make_activity(
activity_type: ActivityType,
*,
quantity: str | None = "1",
unit_price: str | None = "100",
fee: str = "0",
amount: str | None = None,
external_id: str = "x",
) -> Activity:
return Activity(
external_id=external_id,
account_id="acct",
account_type=AccountType.ISA,
date=datetime(2026, 4, 1, tzinfo=UTC),
activity_type=activity_type,
currency="GBP",
quantity=Decimal(quantity) if quantity is not None else None,
unit_price=Decimal(unit_price) if unit_price is not None else None,
fee=Decimal(fee),
amount=Decimal(amount) if amount is not None else None,
)
def test_matched_cash_flow_for_buy_is_deposit_with_total_cost() -> None:
buy = _make_activity(
ActivityType.BUY, quantity="10", unit_price="200.50", fee="1.25",
external_id="buy-1",
)
match = _matched_cash_flow(buy)
assert match is not None
assert match.activity_type is ActivityType.DEPOSIT
assert match.amount == Decimal("2006.25") # 10*200.50 + 1.25
assert match.currency == "GBP"
assert match.account_id == buy.account_id
assert match.date == buy.date
assert match.external_id == "cash-flow-match:buy:buy-1"
def test_matched_cash_flow_for_sell_is_withdrawal_net_of_fee() -> None:
sell = _make_activity(
ActivityType.SELL, quantity="5", unit_price="300", fee="2.50",
external_id="sell-7",
)
match = _matched_cash_flow(sell)
assert match is not None
assert match.activity_type is ActivityType.WITHDRAWAL
assert match.amount == Decimal("1497.50") # 5*300 - 2.50
assert match.external_id == "cash-flow-match:sell:sell-7"
def test_matched_cash_flow_none_for_deposit_withdrawal_dividend() -> None:
dep = _make_activity(ActivityType.DEPOSIT, quantity=None, unit_price=None, amount="100")
wit = _make_activity(ActivityType.WITHDRAWAL, quantity=None, unit_price=None, amount="50")
div = _make_activity(ActivityType.DIVIDEND, quantity=None, unit_price=None, amount="5")
assert _matched_cash_flow(dep) is None
assert _matched_cash_flow(wit) is None
assert _matched_cash_flow(div) is None
def test_matched_cash_flow_skips_zero_amount_trades() -> None:
zero_buy = _make_activity(ActivityType.BUY, quantity="0", unit_price="100")
assert _matched_cash_flow(zero_buy) is None
def test_with_cash_flow_match_returns_pair_for_buy_single_for_deposit() -> None:
buy = _make_activity(ActivityType.BUY, external_id="buy-2")
dep = _make_activity(ActivityType.DEPOSIT, quantity=None, unit_price=None, amount="500")
assert len(_with_cash_flow_match(buy)) == 2
assert len(_with_cash_flow_match(dep)) == 1