diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0d9a23d1..5fe22979 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,30 +9,46 @@ on: - main permissions: - contents: write + contents: read + pages: write + id-token: write jobs: build-docs: runs-on: ubuntu-latest + environment: + name: github-pages steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - uses: abatilo/actions-poetry@v2 - - name: install - run: poetry install --with=docs - - name: Build documentation - run: | - mkdir html - touch html/.nojekyll - poetry run sphinx-build -b html docs html - - name: Deploy documentation - if: ${{ github.event_name == 'push' }} - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages - folder: html + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - name: Install Poetry + run: | + pip install pip poetry setuptools wheel -U + + - name: Install dependencies + run: | + poetry install --with=docs + + - name: Build documentation + run: | + mkdir html + touch html/.nojekyll + poetry run sphinx-build -b html docs html + + - name: Upload documentation artifact + if: ${{ github.event_name == 'push' }} + uses: actions/upload-pages-artifact@v4 + with: + path: html + + - name: Deploy documentation + if: ${{ github.event_name == 'push' }} + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e383e17..a0f46e82 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,28 +1,29 @@ name: ib_async -on: [ push, pull_request ] +on: [push, pull_request] jobs: - build: - # https://github.com/actions/runner-images + build-poetry: + name: poetry (${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10", "pypy3.11" ] + python-version: ["3.11", "3.12", "3.13", "3.14", "pypy3.11"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies of dependencies + - name: Install Poetry toolchain run: | - pip install pip poetry uv setuptools wheel -U + pip install pip poetry setuptools wheel -U - - name: Install dependencies + - name: Install dependencies with Poetry run: | poetry install --with=dev @@ -34,3 +35,36 @@ jobs: - name: Ruff check run: | poetry run ruff check + + build-uv: + name: uv (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14", "pypy3.11"] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv toolchain + run: | + pip install pip uv setuptools wheel -U + + - name: Install dependencies with uv + run: | + uv sync --group dev + + - name: MyPy static code analysis + if: ${{ !startsWith(matrix.python-version, 'pypy') }} + run: | + uv run mypy --pretty ib_async + + - name: Ruff check + run: | + uv run ruff check diff --git a/README.md b/README.md index 713f058a..ecb9aab8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build](https://github.com/ib-api-reloaded/ib_async/actions/workflows/test.yml/badge.svg?branch=next)](https://github.com/ib-api-reloaded/ib_async/actions) [![PyVersion](https://img.shields.io/badge/python-3.10+-blue.svg)](#) [![PyPiVersion](https://img.shields.io/pypi/v/ib_async.svg)](https://pypi.python.org/pypi/ib_async) [![License](https://img.shields.io/badge/license-BSD-blue.svg)](#) [![Docs](https://img.shields.io/badge/Documentation-green.svg)](https://ib-api-reloaded.github.io/ib_async/) +[![Build](https://github.com/ib-api-reloaded/ib_async/actions/workflows/test.yml/badge.svg?branch=next)](https://github.com/ib-api-reloaded/ib_async/actions) [![PyVersion](https://img.shields.io/badge/python-3.11+-blue.svg)](#) [![PyPiVersion](https://img.shields.io/pypi/v/ib_async.svg)](https://pypi.python.org/pypi/ib_async) [![License](https://img.shields.io/badge/license-BSD-blue.svg)](#) [![Docs](https://img.shields.io/badge/Documentation-green.svg)](https://ib-api-reloaded.github.io/ib_async/) # ib_async @@ -32,13 +32,29 @@ and the [API docs](https://ib-api-reloaded.github.io/ib_async/api.html). ## Installation +`ib_async` now targets Python 3.11+ and publishes standard PEP 621 metadata, so the project installs cleanly with `pip`, `uv`, and `poetry`. + +### Install with pip + ``` pip install ib_async ``` +### Install with uv + +``` +uv add ib_async +``` + +### Install with poetry + +``` +poetry add ib_async +``` + Requirements: -- Python 3.10 or higher +- Python 3.11 or higher - We plan to support Python releases [2 years back](https://devguide.python.org/versions/) which allows us to continue adding newer features and performance improvements over time. - A running IB Gateway application (or TWS with API mode enabled) - [stable gateway](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) — updated every few months @@ -50,41 +66,59 @@ The ibapi package from IB is not needed. `ib_async` implements the full IBKR API ## Build Manually -First, install poetry: +First, install your preferred environment manager: ``` pip install poetry -U ``` -### Installing Only Library +or: + +``` +pip install uv -U +``` + +### Install the project with poetry ``` poetry install ``` -### Install Everything (enable docs + dev testing) +### Install the project with uv ``` +uv sync +``` + +### Install everything for development and docs + +```bash poetry install --with=docs,dev +uv sync --group dev --group docs ``` ## Generate Docs -``` +```bash poetry install --with=docs poetry run sphinx-build -b html docs html + +uv sync --group docs +uv run sphinx-build -b html docs html ``` ## Check Types -``` +```bash poetry run mypy ib_async +uv run mypy ib_async ``` ## Build Package -``` +```bash poetry build +python -m build ``` ## Upload Package (if maintaining) @@ -481,12 +515,16 @@ The complete [API documentation](https://ib-api-reloaded.github.io/ib_async/api. ```bash poetry install --with=dev poetry run pytest + +uv sync --group dev +uv run pytest ``` ### Type Checking ```bash poetry run mypy ib_async +uv run mypy ib_async ``` ### Code Formatting @@ -494,6 +532,9 @@ poetry run mypy ib_async ```bash poetry run ruff format poetry run ruff check --fix + +uv run ruff format +uv run ruff check --fix ``` ### Local Development @@ -507,6 +548,7 @@ cd ib_async 2. Install dependencies: ```bash poetry install --with=dev,docs +uv sync --group dev --group docs ``` 3. Make your changes and run tests: diff --git a/ib_async/ib.py b/ib_async/ib.py index 87e63fee..3edfb8de 100644 --- a/ib_async/ib.py +++ b/ib_async/ib.py @@ -209,7 +209,8 @@ class IB: A profit- and loss entry for a single position is updated. * ``tickNewsEvent`` (news: :class:`.NewsTick`): - Emit a new news headline. + Emit a new news headline with the associated contract when + available. * ``newsBulletinEvent`` (bulletin: :class:`.NewsBulletin`): Emit a new news bulletin. @@ -457,7 +458,7 @@ def waitOnUpdate(self, timeout: float = 0) -> bool: if timeout: try: util.run(asyncio.wait_for(self.updateEvent, timeout)) - except asyncio.TimeoutError: + except TimeoutError: return False else: util.run(self.updateEvent) @@ -2096,7 +2097,7 @@ async def connectAsync( if fetchFields & StartupFetch.EXECUTIONS: try: await asyncio.wait_for(self.reqExecutionsAsync(), timeout) - except asyncio.TimeoutError: + except TimeoutError: msg = "executions request timed out" errors.append(msg) self._logger.error(msg) @@ -2315,7 +2316,7 @@ async def reqMatchingSymbolsAsync( try: await asyncio.wait_for(future, 4) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("reqMatchingSymbolsAsync: Timeout") return None @@ -2327,7 +2328,7 @@ async def reqMarketRuleAsync( self.client.reqMarketRule(marketRuleId) await asyncio.wait_for(future, 1) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("reqMarketRuleAsync: Timeout") return None @@ -2375,7 +2376,7 @@ async def reqHistoricalDataAsync( task = asyncio.wait_for(future, timeout) if timeout else future try: await task - except asyncio.TimeoutError: + except TimeoutError: self.client.cancelHistoricalData(reqId) self._logger.warning(f"reqHistoricalData: Timeout for {contract}") bars.clear() @@ -2520,7 +2521,7 @@ async def calculateImpliedVolatilityAsync( try: await asyncio.wait_for(future, 4) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("calculateImpliedVolatilityAsync: Timeout") return None finally: @@ -2541,7 +2542,7 @@ async def calculateOptionPriceAsync( try: await asyncio.wait_for(future, 4) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("calculateOptionPriceAsync: Timeout") return None finally: @@ -2596,7 +2597,7 @@ async def reqHistoricalNewsAsync( try: await asyncio.wait_for(future, 4) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("reqHistoricalNewsAsync: Timeout") return None @@ -2606,7 +2607,7 @@ async def requestFAAsync(self, faDataType: int): try: await asyncio.wait_for(future, 4) return future.result() - except asyncio.TimeoutError: + except TimeoutError: self._logger.error("requestFAAsync: Timeout") async def getWshMetaDataAsync(self) -> str: diff --git a/ib_async/objects.py b/ib_async/objects.py index f01a66fb..927a31ae 100644 --- a/ib_async/objects.py +++ b/ib_async/objects.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import UTC, datetime, tzinfo from datetime import date as date_ -from datetime import datetime, timezone, tzinfo from typing import Any, NamedTuple from eventkit import Event @@ -452,6 +452,7 @@ class NewsTick: articleId: str headline: str extraData: str + contract: Contract | None = None @dataclass(slots=True, frozen=True) @@ -591,4 +592,4 @@ class IBDefaults: unset: Any = nan # optionally change the timezone used for log history events in objects (no impact on orders or data processing) - timezone: tzinfo = timezone.utc + timezone: tzinfo = UTC diff --git a/ib_async/util.py b/ib_async/util.py index fdf05d4c..5795f852 100644 --- a/ib_async/util.py +++ b/ib_async/util.py @@ -23,7 +23,7 @@ Event to emit global exceptions. """ -EPOCH: Final = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) +EPOCH: Final = dt.datetime(1970, 1, 1, tzinfo=dt.UTC) UNSET_INTEGER: Final = 2**31 - 1 UNSET_DOUBLE: Final = sys.float_info.max @@ -421,7 +421,7 @@ def timeRange(start: Time_t, end: Time_t, step: float) -> Iterator[dt.datetime]: assert step > 0 delta = dt.timedelta(seconds=step) t = _fillDate(start) - tz = dt.timezone.utc if t.tzinfo else None + tz = dt.UTC if t.tzinfo else None now = dt.datetime.now(tz) while t < now: t += delta @@ -454,7 +454,7 @@ async def timeRangeAsync( delta = dt.timedelta(seconds=step) t = _fillDate(start) - tz = dt.timezone.utc if t.tzinfo else None + tz = dt.UTC if t.tzinfo else None now = dt.datetime.now(tz) while t < now: t += delta @@ -577,11 +577,11 @@ def formatIBDatetime(t: dt.date | dt.datetime | str | None) -> str: s = "" elif isinstance(t, dt.datetime): # convert to UTC timezone - t = t.astimezone(tz=dt.timezone.utc) + t = t.astimezone(tz=dt.UTC) s = t.strftime("%Y%m%d %H:%M:%S UTC") elif isinstance(t, dt.date): t = dt.datetime(t.year, t.month, t.day, 23, 59, 59).astimezone( - tz=dt.timezone.utc + tz=dt.UTC ) s = t.strftime("%Y%m%d %H:%M:%S UTC") else: @@ -599,7 +599,7 @@ def parseIBDatetime(s: str) -> dt.date | dt.datetime: d = int(s[6:8]) t = dt.date(y, m, d) elif s.isdigit(): - t = dt.datetime.fromtimestamp(int(s), dt.timezone.utc) + t = dt.datetime.fromtimestamp(int(s), dt.UTC) elif s.count(" ") >= 2 and " " not in s: # 20221125 10:00:00 Europe/Amsterdam s0, s1, s2 = s.split(" ", 2) diff --git a/ib_async/wrapper.py b/ib_async/wrapper.py index 40a228c8..1fa80d7a 100644 --- a/ib_async/wrapper.py +++ b/ib_async/wrapper.py @@ -410,6 +410,21 @@ def _endReq(self, key, result=None, success=True): else: future.set_exception(result) + def _snapshotContractForReqId(self, reqId: int) -> Contract | None: + """ + Return a stable contract snapshot for a market-data request id. + + Prefer the live ticker mapping because that is the primary owner of + market-data request identity. Fall back to the generic request + contract map when no ticker is registered for the reqId. + """ + ticker = self.reqId2Ticker.get(reqId) + if ticker: + return Contract.recreate(ticker.contract) + + contract = self._reqId2Contract.get(reqId) + return Contract.recreate(contract) if contract else None + def startTicker(self, reqId: int, contract: Contract, tickType: int | str): """ Start a tick request that has the reqId associated with the contract. @@ -1488,14 +1503,21 @@ def newsProviders(self, newsProviders: list[NewsProvider]): def tickNews( self, - _reqId: int, + reqId: int, timeStamp: int, providerCode: str, articleId: str, headline: str, extraData: str, ): - news = NewsTick(timeStamp, providerCode, articleId, headline, extraData) + news = NewsTick( + timeStamp, + providerCode, + articleId, + headline, + extraData, + contract=self._snapshotContractForReqId(reqId), + ) self.newsTicks.append(news) self.ib.tickNewsEvent.emit(news) diff --git a/pyproject.toml b/pyproject.toml index 270c2ed7..bea8ea6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,42 +1,57 @@ [project] name = "ib_async" -requires-python = ">=3.10" -version = "2.1.0" -license = "BSD" -license-files = ["LICENSE"] - -[tool.poetry] -name = "ib_async" version = "2.1.0" description = "Python sync/async framework for Interactive Brokers API" -authors = ["Ewald de Wit"] -maintainers = ["Matt Stancliff "] -license = "BSD" readme = "README.md" -repository = "https://github.com/ib-api-reloaded/ib_async" -include = ["ib_async/py.typed"] +license = "BSD-2-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.11" +authors = [{ name = "Ewald de Wit" }] +maintainers = [{ name = "Matt Stancliff", email = "matt@matt.sh" }] +keywords = ["ibapi", "tws", "asyncio", "jupyter", "interactive", "brokers", "async", "ib_async", "ib_insync"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Office/Business :: Financial :: Investment", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", ] -keywords = ["ibapi", "tws", "asyncio", "jupyter", "interactive", "brokers", "async", "ib_async", "ib_insync"] +dependencies = [ + "aeventkit>=2.1.0,<3.0.0", + "nest_asyncio", + "tzdata>=2025.2" +] + +[project.urls] +Repository = "https://github.com/ib-api-reloaded/ib_async" +"Bug Tracker" = "https://github.com/ib-api-reloaded/ib_async/issues" + +[dependency-groups] +dev = [ + "mypy>=1.11.0", + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pandas>=2.2.1,<3.0.0; implementation_name != 'pypy'", + "ruff>=0.11.13,<0.12.0", +] +docs = [ + "sphinx-autodoc-typehints>=2.0.0,<3.0.0", + "sphinx-rtd-theme>=2.0.0,<3.0.0", + "myst-parser>=2.0.0,<3.0.0", +] + +[tool.poetry] +include = ["ib_async/py.typed"] [tool.poetry.dependencies] -python = ">=3.10" +python = ">=3.11" aeventkit = "^2.1.0" # aeventkit = { path = "../eventkit", develop = true } nest_asyncio = "*" -tzdata = "^2025.2" - -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/ib-api-reloaded/ib_async/issues" - +tzdata = ">=2025.2" [tool.poetry.group.dev] optional = true @@ -45,7 +60,7 @@ optional = true mypy = ">=1.11.0" pytest = ">=8.0" pytest-asyncio = ">=0.23" -pandas = "^2.2.1" +pandas = { version = "^2.2.1", markers = "implementation_name != 'pypy'" } ruff = "^0.11.13" @@ -72,7 +87,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff] -target-version = "py310" +target-version = "py311" exclude = [ "notebooks/", "upstream_api_architecture/", diff --git a/tests/test_news.py b/tests/test_news.py new file mode 100644 index 00000000..97bd600c --- /dev/null +++ b/tests/test_news.py @@ -0,0 +1,64 @@ +import ib_async as ibi +from ib_async import IB, Stock +from ib_async.ticker import Ticker + + +def test_tickNews_populates_contract_from_active_ticker(): + """When a ticker is registered for the incoming reqId (the normal + streaming-news flow via reqMktData), the emitted NewsTick carries the + originating contract as a typed snapshot.""" + ib = IB() + contract = Stock("AAPL", "SMART", "USD") + ib.wrapper.reqId2Ticker[1] = Ticker(contract=contract, defaults=ib.wrapper.defaults) + + captured: list[ibi.NewsTick] = [] + ib.tickNewsEvent += captured.append + + ib.wrapper.tickNews( + 1, + 1_700_000_000, + "BRFG", + "BRFG$abc", + "Apple announces new chip", + "", + ) + + assert len(captured) == 1 + news = captured[0] + assert news.headline == "Apple announces new chip" + assert news.contract is not None + assert news.contract.symbol == "AAPL" + assert news.contract.secType == "STK" + # recreate() returns a new instance so the snapshot is independent of + # later mutations on the original contract. + assert news.contract is not contract + + +def test_tickNews_falls_back_to_reqId2Contract(): + """When no ticker is registered but the reqId exists in the generic + request-contract map, the fallback lookup still populates the contract.""" + ib = IB() + contract = Stock("MSFT", "SMART", "USD") + ib.wrapper._reqId2Contract[42] = contract + + captured: list[ibi.NewsTick] = [] + ib.tickNewsEvent += captured.append + + ib.wrapper.tickNews(42, 1_700_000_000, "BRFG", "BRFG$x", "Headline", "") + + assert len(captured) == 1 + assert captured[0].contract is not None + assert captured[0].contract.symbol == "MSFT" + + +def test_tickNews_contract_none_when_reqId_unknown(): + """When neither map knows the reqId, the NewsTick still emits with + contract=None rather than raising.""" + ib = IB() + captured: list[ibi.NewsTick] = [] + ib.tickNewsEvent += captured.append + + ib.wrapper.tickNews(999, 1_700_000_000, "BRFG", "BRFG$x", "Headline", "") + + assert len(captured) == 1 + assert captured[0].contract is None