Compare commits

..

No commits in common. "a4dab03bc5b0b8cb9500e39e2cb660858214a4c6" and "c271d5101c05eaebf84c1cfc654b006443c2f903" have entirely different histories.

10 changed files with 1 additions and 992 deletions

View file

@ -230,100 +230,6 @@ def invest_engine(
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: str = typer.Option(..., envvar="IBKR_ACCOUNT_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.
"""
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,
wf_account_id=ibkr_account_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,
)
# Reconciliation: broker truth vs WF truth.
wf_qty = await sink.compute_position_qty(ibkr_account_id)
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),
)
)
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"),

View file

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

View file

@ -1,257 +0,0 @@
"""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,
wf_account_id: str,
upstream_account_id: str,
) -> None:
self._token = token
self._query_id = query_id
self._wf_account_id = wf_account_id
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._wf_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._wf_account_id)
for cash in stmt.CashTransactions or []:
activity = _map_cash_to_activity(cash, account_id=self._wf_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

View file

@ -315,50 +315,6 @@ class WealthfolioSink:
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(

199
poetry.lock generated
View file

@ -73,145 +73,6 @@ files = [
{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]]
name = "click"
version = "8.1.8"
@ -373,24 +234,6 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
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]]
name = "idna"
version = "3.11"
@ -820,28 +663,6 @@ files = [
[package.dependencies]
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]]
name = "rich"
version = "15.0.0"
@ -979,24 +800,6 @@ files = [
{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]]
name = "yapf"
version = "0.43.0"
@ -1015,4 +818,4 @@ platformdirs = ">=3.5.1"
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "8a704e79729d5bd3cbe78a7e35c51e9da724880915c0152788273b94bd00610d"
content-hash = "b3896b2258a425cce9498be9ada5bd48a06d5f2bd7c53ead044ad27c53086bd7"

View file

@ -18,9 +18,6 @@ aiomysql = "^0.3.2"
# 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]
pytest = "^8.3"

View file

@ -1,21 +0,0 @@
<?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>
</FlexStatement>
</FlexStatements>
</FlexQueryResponse>

View file

@ -1,199 +0,0 @@
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",
wf_account_id="wf-acct",
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",
wf_account_id="wf-acct",
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",
wf_account_id="wf-acct",
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")}

View file

@ -373,62 +373,3 @@ async def test_push_manual_snapshots_short_circuits_on_empty(
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")}

View file

@ -1,66 +0,0 @@
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)])