Compare commits
6 commits
c271d5101c
...
a4dab03bc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4dab03bc5 | ||
|
|
e83c5a0a8f | ||
|
|
882415464e | ||
|
|
975c3b4bf7 | ||
|
|
82797908b7 | ||
|
|
7cba540c37 |
10 changed files with 992 additions and 1 deletions
|
|
@ -230,6 +230,100 @@ 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"),
|
||||
|
|
|
|||
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)
|
||||
257
broker_sync/providers/ibkr.py
Normal file
257
broker_sync/providers/ibkr.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""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
|
||||
|
|
@ -315,6 +315,50 @@ 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
199
poetry.lock
generated
|
|
@ -73,6 +73,145 @@ 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"
|
||||
|
|
@ -234,6 +373,24 @@ 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"
|
||||
|
|
@ -663,6 +820,28 @@ 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"
|
||||
|
|
@ -800,6 +979,24 @@ 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"
|
||||
|
|
@ -818,4 +1015,4 @@ platformdirs = ">=3.5.1"
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "b3896b2258a425cce9498be9ada5bd48a06d5f2bd7c53ead044ad27c53086bd7"
|
||||
content-hash = "8a704e79729d5bd3cbe78a7e35c51e9da724880915c0152788273b94bd00610d"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ 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"
|
||||
|
|
|
|||
21
tests/fixtures/ibkr/sample_flex.xml
vendored
Normal file
21
tests/fixtures/ibkr/sample_flex.xml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?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>
|
||||
199
tests/providers/test_ibkr.py
Normal file
199
tests/providers/test_ibkr.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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")}
|
||||
|
|
@ -373,3 +373,62 @@ 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")}
|
||||
|
|
|
|||
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)])
|
||||
Loading…
Add table
Add a link
Reference in a new issue