Compare commits
47 commits
ie-bearer-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b632d951e4 | ||
|
|
0d23487608 | ||
|
|
3427f5c9e1 | ||
|
|
17c2a69c6c | ||
|
|
abf9fa7cb5 | ||
|
|
bb9e0d4567 | ||
|
|
ceb652b623 | ||
|
|
30af5fe2c9 | ||
|
|
0ab069349f | ||
|
|
2fb1fbbdd8 | ||
|
|
a4dab03bc5 | ||
|
|
e83c5a0a8f | ||
|
|
882415464e | ||
|
|
975c3b4bf7 | ||
|
|
82797908b7 | ||
|
|
7cba540c37 | ||
|
|
c271d5101c | ||
|
|
ca5f98f771 | ||
|
|
e6ef1fce97 | ||
|
|
fe35c8e826 | ||
|
|
68d4832c2e | ||
| d5dbeb96af | |||
| d860aef927 | |||
| 98c4729622 | |||
| c9c0310733 | |||
| cb159e17d9 | |||
|
|
5adc4a7ba4 | ||
|
|
f4a4c8892f | ||
|
|
dfee29fda7 | ||
|
|
1d1e20b72b | ||
|
|
6f3bcea23e | ||
|
|
6450201af0 | ||
|
|
7c9be544dc | ||
|
|
804e6a89de | ||
|
|
832732a419 | ||
|
|
c830856ba1 | ||
|
|
a190875f63 | ||
|
|
74b2179c83 | ||
|
|
4e2da87637 | ||
|
|
6efd03570a | ||
|
|
f089b8b93a | ||
|
|
1aa60ce348 | ||
|
|
89e9710d24 | ||
|
|
87526898e6 | ||
|
|
020ba16723 | ||
|
|
72d348e294 | ||
|
|
9ec8ece2d9 |
37 changed files with 8043 additions and 82 deletions
103
.github/workflows/build.yml
vendored
Normal file
103
.github/workflows/build.yml
vendored
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
44
Dockerfile
44
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
51
broker_sync/metrics.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,21 +52,26 @@ 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).
|
||||||
continue
|
# See `_matched_cash_flow` — without the match, WF's historical Net
|
||||||
new_after_dedup += 1
|
# Worth chart shows phantom spikes because BUYs consume cash that
|
||||||
_tag_notes(activity, provider.name)
|
# was never "deposited" according to the activity log.
|
||||||
original_account_id = activity.account_id
|
for act in _with_cash_flow_match(activity):
|
||||||
# Submit under Wealthfolio's UUID; keep dedup keyed on our id.
|
if dedup.has_seen(provider.name, act.account_id, act.external_id):
|
||||||
wf_id = wf_account_ids.get(original_account_id)
|
continue
|
||||||
if wf_id:
|
new_after_dedup += 1
|
||||||
activity.account_id = wf_id
|
_tag_notes(act, provider.name)
|
||||||
batch.append((original_account_id, activity))
|
original_account_id = act.account_id
|
||||||
if len(batch) >= _BATCH_SIZE:
|
# Submit under Wealthfolio's UUID; keep dedup keyed on our id.
|
||||||
ok, bad = await _flush_batch(sink, dedup, provider.name, batch)
|
wf_id = wf_account_ids.get(original_account_id)
|
||||||
imported += ok
|
if wf_id:
|
||||||
failed += bad
|
act.account_id = wf_id
|
||||||
batch = []
|
batch.append((original_account_id, act))
|
||||||
|
if len(batch) >= _BATCH_SIZE:
|
||||||
|
ok, bad = await _flush_batch(sink, dedup, provider.name, batch)
|
||||||
|
imported += ok
|
||||||
|
failed += bad
|
||||||
|
batch = []
|
||||||
|
|
||||||
if batch:
|
if batch:
|
||||||
ok, bad = await _flush_batch(sink, dedup, provider.name, batch)
|
ok, bad = await _flush_batch(sink, dedup, provider.name, batch)
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
331
broker_sync/providers/fidelity_planviewer.py
Normal file
331
broker_sync/providers/fidelity_planviewer.py
Normal 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
|
||||||
144
broker_sync/providers/finance_mysql.py
Normal file
144
broker_sync/providers/finance_mysql.py
Normal 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()
|
||||||
276
broker_sync/providers/ibkr.py
Normal file
276
broker_sync/providers/ibkr.py
Normal 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
|
||||||
297
broker_sync/providers/imap.py
Normal file
297
broker_sync/providers/imap.py
Normal 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()}")
|
||||||
129
broker_sync/providers/parsers/fidelity.py
Normal file
129
broker_sync/providers/parsers/fidelity.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
return []
|
activities = _parse_rfc2822_lines(text_body)
|
||||||
return _parse_rfc2822_lines(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 []
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
payload = part.get_payload(decode=True)
|
return _decode_payload(part)
|
||||||
if isinstance(payload, bytes):
|
|
||||||
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
|
||||||
return None
|
return None
|
||||||
payload = msg.get_payload(decode=True)
|
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)
|
||||||
if isinstance(payload, bytes):
|
if isinstance(payload, bytes):
|
||||||
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
return payload.decode(part.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,
|
||||||
|
|
|
||||||
125
broker_sync/providers/parsers/schwab.py
Normal file
125
broker_sync/providers/parsers/schwab.py
Normal 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 []
|
||||||
|
|
@ -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"]
|
||||||
assert isinstance(got, list)
|
summary = raw.get("summary") if isinstance(raw.get("summary"), dict) else None
|
||||||
return got
|
elif isinstance(raw, list):
|
||||||
if isinstance(raw, list):
|
got = raw
|
||||||
return raw
|
summary = None
|
||||||
return []
|
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)
|
||||||
|
return [r for r in got if isinstance(r, dict)]
|
||||||
|
|
||||||
|
# -- activity lookups --
|
||||||
|
|
||||||
|
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()},
|
||||||
|
}
|
||||||
|
|
|
||||||
1580
docs/plans/2026-05-26-ibkr-flex-ingestion.md
Normal file
1580
docs/plans/2026-05-26-ibkr-flex-ingestion.md
Normal file
File diff suppressed because it is too large
Load diff
111
docs/providers/fidelity-planviewer.md
Normal file
111
docs/providers/fidelity-planviewer.md
Normal 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
127
docs/providers/ibkr.md
Normal 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 (5–7 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>
|
||||||
328
docs/specs/2026-05-26-ibkr-ingest-design.md
Normal file
328
docs/specs/2026-05-26-ibkr-ingest-design.md
Normal 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 5–20 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** (5–7 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
359
poetry.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
1707
tests/fixtures/fidelity/transactions-full.html
vendored
Normal file
1707
tests/fixtures/fidelity/transactions-full.html
vendored
Normal file
File diff suppressed because one or more lines are too long
2
tests/fixtures/fidelity/valuation.json
vendored
Normal file
2
tests/fixtures/fidelity/valuation.json
vendored
Normal 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
25
tests/fixtures/ibkr/sample_flex.xml
vendored
Normal 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>
|
||||||
22
tests/fixtures/invest_engine/csv_attachment.eml
vendored
Normal file
22
tests/fixtures/invest_engine/csv_attachment.eml
vendored
Normal 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--
|
||||||
40
tests/fixtures/invest_engine/html_partial_match.eml
vendored
Normal file
40
tests/fixtures/invest_engine/html_partial_match.eml
vendored
Normal 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&P 500: VUAG</td></tr>
|
||||||
|
<tr><td>Bought 3.0 @ £61.25 per share</td></tr>
|
||||||
|
<tr><td>Total: £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--
|
||||||
55
tests/fixtures/invest_engine/html_two_orders.eml
vendored
Normal file
55
tests/fixtures/invest_engine/html_two_orders.eml
vendored
Normal 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&P 500: VUAG</td></tr>
|
||||||
|
<tr><td>Bought 10.5 @ £62.10 per share</td></tr>
|
||||||
|
<tr><td>Total: £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 @ £85.40 per share</td></tr>
|
||||||
|
<tr><td>Total: £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--
|
||||||
15
tests/fixtures/invest_engine/unparseable.eml
vendored
Normal file
15
tests/fixtures/invest_engine/unparseable.eml
vendored
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
145
tests/providers/parsers/test_schwab.py
Normal file
145
tests/providers/parsers/test_schwab.py
Normal 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
|
||||||
209
tests/providers/test_fidelity_planviewer.py
Normal file
209
tests/providers/test_fidelity_planviewer.py
Normal 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
|
||||||
66
tests/providers/test_finance_mysql.py
Normal file
66
tests/providers/test_finance_mysql.py
Normal 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"
|
||||||
224
tests/providers/test_ibkr.py
Normal file
224
tests/providers/test_ibkr.py
Normal 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() == []
|
||||||
206
tests/providers/test_imap.py
Normal file
206
tests/providers/test_imap.py
Normal 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
|
||||||
|
|
@ -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,21 +225,25 @@ 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",
|
{
|
||||||
"isValid": True,
|
"symbol": "VUAG",
|
||||||
"errors": None,
|
"isValid": True,
|
||||||
"assetId": "enriched-asset-uuid",
|
"errors": None,
|
||||||
"exchangeMic": "XLON",
|
"assetId": "enriched-asset-uuid",
|
||||||
},
|
"exchangeMic": "XLON",
|
||||||
])
|
},
|
||||||
|
])
|
||||||
if req.url.path == "/api/v1/activities/import":
|
if req.url.path == "/api/v1/activities/import":
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
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
66
tests/test_metrics.py
Normal 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)])
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue