From d6f91bff78af6244cb9ddc2a6d655c0d2c8425db Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 19:29:33 +0100 Subject: [PATCH 1/4] ci: add CI/CD workflows and track uv.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track uv.lock for reproducible CI builds (uv sync --frozen) - Add CI workflow: ruff check, ruff format, mypy, pytest (3.11–3.13) - Add publish workflow: OIDC trusted publishing to PyPI on v* tags - Fix all ruff and mypy errors across source and test files --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++ .github/workflows/publish.yml | 42 ++++++++++++++++++++++ .gitignore | 2 -- etoropy/http/retry.py | 4 +-- etoropy/models/feeds.py | 4 --- etoropy/models/market_data.py | 4 +-- etoropy/models/trading.py | 7 ---- etoropy/models/websocket.py | 2 -- etoropy/rest/discovery.py | 6 ++-- etoropy/rest/market_data.py | 1 + etoropy/rest/trading_execution.py | 4 ++- etoropy/rest/watchlists.py | 8 ++--- etoropy/trading/client.py | 13 +++---- etoropy/ws/client.py | 7 ++-- tests/unit/test_config.py | 2 -- tests/unit/test_errors.py | 2 -- tests/unit/test_models.py | 3 -- tests/unit/test_rate_limiter.py | 1 - tests/unit/test_ws_message_parser.py | 20 +++++++++-- 19 files changed, 134 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bbb353e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & type-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: uv sync --frozen --all-extras + + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy etoropy/ + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - run: uv sync --frozen --all-extras + - run: uv run pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a2a3239 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,42 @@ +name: Publish to PyPI + +on: + push: + tags: ["v*"] + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: uv build + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index a198fec..e7345ae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,3 @@ coverage.xml .mypy_cache/ .ruff_cache/ -# uv -uv.lock diff --git a/etoropy/http/retry.py b/etoropy/http/retry.py index 3d7a89f..c1f8fe6 100644 --- a/etoropy/http/retry.py +++ b/etoropy/http/retry.py @@ -29,7 +29,7 @@ class RetryOptions: delay: float = 1.0 # seconds backoff_multiplier: float = 2.0 jitter: bool = True - should_retry: Callable[[BaseException], bool] = field(default=lambda: lambda _: False) + should_retry: Callable[[BaseException], bool] = field(default_factory=lambda: lambda _: False) get_retry_after_s: Callable[[BaseException], float | None] | None = None on_retry: Callable[[int, float, BaseException], None] | None = None @@ -50,7 +50,7 @@ async def retry(fn: Callable[[], Any], options: RetryOptions) -> Any: for attempt in range(options.attempts): try: - return await fn() # type: ignore[misc] + return await fn() except Exception as error: last_error = error if attempt < options.attempts - 1 and options.should_retry(error): diff --git a/etoropy/models/feeds.py b/etoropy/models/feeds.py index 7616ccb..26f53ae 100644 --- a/etoropy/models/feeds.py +++ b/etoropy/models/feeds.py @@ -32,7 +32,6 @@ class GetFeedParams(BaseModel): page_size: int | None = None - class CreateCommentRequest(BaseModel): post_id: str = Field(alias="postId", serialization_alias="postId") content: str @@ -49,7 +48,6 @@ class Comment(BaseModel): created_at: str = Field(alias="createdAt") - class UserSearchParams(BaseModel): search_text: str | None = None page: int | None = None @@ -77,7 +75,6 @@ class UserPortfolio(BaseModel): positions: list[Any] = [] - class CopierInfo(BaseModel): model_config = {"extra": "allow"} @@ -85,7 +82,6 @@ class CopierInfo(BaseModel): copiers_count: int = Field(0, alias="copiersCount") - class CuratedList(BaseModel): model_config = {"extra": "allow"} diff --git a/etoropy/models/market_data.py b/etoropy/models/market_data.py index 2b8be04..f2fa0ba 100644 --- a/etoropy/models/market_data.py +++ b/etoropy/models/market_data.py @@ -72,9 +72,7 @@ class InstrumentDisplayData(BaseModel): class InstrumentsResponse(BaseModel): - instrument_display_datas: list[InstrumentDisplayData] = Field( - default_factory=list, alias="instrumentDisplayDatas" - ) + instrument_display_datas: list[InstrumentDisplayData] = Field(default_factory=list, alias="instrumentDisplayDatas") class GetInstrumentsParams(BaseModel): diff --git a/etoropy/models/trading.py b/etoropy/models/trading.py index d778fb1..0494f7d 100644 --- a/etoropy/models/trading.py +++ b/etoropy/models/trading.py @@ -48,7 +48,6 @@ class ClosePositionRequest(BaseModel): units_to_deduct: float | None = Field(None, alias="UnitsToDeduct", serialization_alias="UnitsToDeduct") - class OrderForOpen(BaseModel): instrument_id: int = Field(alias="instrumentID") amount: float @@ -76,7 +75,6 @@ class OrderForCloseResponse(BaseModel): token: str - class OrderPositionInfo(BaseModel): model_config = {"extra": "allow"} @@ -109,7 +107,6 @@ class OrderForOpenInfoResponse(BaseModel): positions: list[OrderPositionInfo] = [] - class Position(BaseModel): model_config = {"extra": "allow"} @@ -150,7 +147,6 @@ class Position(BaseModel): lot_count: float = Field(0.0, alias="lotCount") - class PendingOrder(BaseModel): model_config = {"extra": "allow"} @@ -169,7 +165,6 @@ class PendingOrder(BaseModel): execution_type: int = Field(0, alias="executionType") - class Mirror(BaseModel): model_config = {"extra": "allow"} @@ -195,7 +190,6 @@ class Mirror(BaseModel): orders_for_close_multiple: list[Any] = Field(default_factory=list, alias="ordersForCloseMultiple") - class ClientPortfolio(BaseModel): positions: list[Position] = [] credit: float = 0.0 @@ -215,7 +209,6 @@ class PnlResponse(BaseModel): client_portfolio: ClientPortfolio = Field(alias="clientPortfolio") - class TradeHistoryParams(BaseModel): min_date: str page: int | None = None diff --git a/etoropy/models/websocket.py b/etoropy/models/websocket.py index 77e2a98..80c21e5 100644 --- a/etoropy/models/websocket.py +++ b/etoropy/models/websocket.py @@ -23,7 +23,6 @@ class WsUnsubscribeOperation(BaseModel): data: dict[str, list[str]] - class WsMessage(BaseModel): topic: str content: str @@ -35,7 +34,6 @@ class WsEnvelope(BaseModel): messages: list[WsMessage] = [] - class WsInstrumentRate(BaseModel): ask: float = Field(alias="Ask") bid: float = Field(alias="Bid") diff --git a/etoropy/rest/discovery.py b/etoropy/rest/discovery.py index 4619426..b4f8bdc 100644 --- a/etoropy/rest/discovery.py +++ b/etoropy/rest/discovery.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from ..config.constants import API_PREFIX from ._base import BaseRestClient @@ -8,7 +8,7 @@ class DiscoveryClient(BaseRestClient): async def get_curated_lists(self) -> list[Any]: - return await self._get(f"{API_PREFIX}/watchlists/curated") + return cast(list[Any], await self._get(f"{API_PREFIX}/watchlists/curated")) async def get_market_recommendations(self) -> list[Any]: - return await self._get(f"{API_PREFIX}/watchlists/recommendations") + return cast(list[Any], await self._get(f"{API_PREFIX}/watchlists/recommendations")) diff --git a/etoropy/rest/market_data.py b/etoropy/rest/market_data.py index ab0368f..75d7c60 100644 --- a/etoropy/rest/market_data.py +++ b/etoropy/rest/market_data.py @@ -26,6 +26,7 @@ class MarketDataClient(BaseRestClient): one request per instrument ID using ``asyncio.gather()``, because the eToro API returns 500 when multiple IDs are comma-separated. """ + async def search_instruments( self, fields: str, diff --git a/etoropy/rest/trading_execution.py b/etoropy/rest/trading_execution.py index 72b35c4..8d4731f 100644 --- a/etoropy/rest/trading_execution.py +++ b/etoropy/rest/trading_execution.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ..config.constants import API_PREFIX from ..http.client import HttpClient from ..models.common import TokenResponse @@ -51,7 +53,7 @@ async def cancel_limit_order(self, order_id: int) -> TokenResponse: async def close_position( self, position_id: int, params: ClosePositionRequest | None = None ) -> OrderForCloseResponse: - body = params if params is not None else {} + body: ClosePositionRequest | dict[str, Any] = params if params is not None else {} data = await self._post(f"{self._path_prefix}/market-close-orders/positions/{position_id}", body) return OrderForCloseResponse.model_validate(data) diff --git a/etoropy/rest/watchlists.py b/etoropy/rest/watchlists.py index 9ca70bc..581e5c5 100644 --- a/etoropy/rest/watchlists.py +++ b/etoropy/rest/watchlists.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from ..config.constants import API_PREFIX from ._base import BaseRestClient @@ -8,13 +8,13 @@ class WatchlistsClient(BaseRestClient): async def get_user_watchlists(self) -> list[Any]: - return await self._get(f"{API_PREFIX}/watchlists") + return cast(list[Any], await self._get(f"{API_PREFIX}/watchlists")) async def get_watchlist(self, watchlist_id: int) -> Any: return await self._get(f"{API_PREFIX}/watchlists/{watchlist_id}") async def get_default_watchlist_items(self) -> list[Any]: - return await self._get(f"{API_PREFIX}/watchlists/default/items") + return cast(list[Any], await self._get(f"{API_PREFIX}/watchlists/default/items")) async def create_watchlist(self, name: str, items: list[int] | None = None) -> Any: body: dict[str, Any] = {"name": name} @@ -50,7 +50,7 @@ async def change_rank(self, watchlist_id: int, rank: int) -> None: await self._put(f"{API_PREFIX}/watchlists/{watchlist_id}/rank", {"rank": rank}) async def get_public_watchlists(self, user_id: int) -> list[Any]: - return await self._get(f"{API_PREFIX}/watchlists/users/{user_id}/public") + return cast(list[Any], await self._get(f"{API_PREFIX}/watchlists/users/{user_id}/public")) async def get_public_watchlist(self, watchlist_id: int) -> Any: return await self._get(f"{API_PREFIX}/watchlists/public/{watchlist_id}") diff --git a/etoropy/trading/client.py b/etoropy/trading/client.py index 4c73672..5cedf13 100644 --- a/etoropy/trading/client.py +++ b/etoropy/trading/client.py @@ -118,6 +118,7 @@ def off(self, event: str, handler: EventHandler) -> EToroTrading: def once(self, event: str, handler: EventHandler) -> EToroTrading: """Register *handler* for *event*, then auto-unregister after the first call.""" + def wrapper(*args: Any, **kwargs: Any) -> Any: self.off(event, wrapper) return handler(*args, **kwargs) @@ -351,9 +352,7 @@ async def cancel_all_orders(self) -> list[TokenResponse]: """Cancel all pending market orders (runs in parallel).""" portfolio = await self.get_portfolio() orders = portfolio.client_portfolio.orders_for_open - return list( - await asyncio.gather(*(self.rest.execution.cancel_market_open_order(o.order_id) for o in orders)) - ) + return list(await asyncio.gather(*(self.rest.execution.cancel_market_open_order(o.order_id) for o in orders))) async def cancel_all_limit_orders(self) -> list[TokenResponse]: """Cancel all pending limit orders (runs in parallel).""" @@ -479,9 +478,7 @@ def handler(event: WsPrivateEvent) -> None: status_name = OrderStatusId(event.status_id).name reason = event.error_message or event.close_reason or "unknown reason" event_future.set_exception( - EToroError( - f"Order {order_id} {status_name}: {reason} (errorCode: {event.error_code or 'none'})" - ) + EToroError(f"Order {order_id} {status_name}: {reason} (errorCode: {event.error_code or 'none'})") ) self.on("order:update", handler) @@ -543,9 +540,7 @@ async def _poll_order_status( return info if info.status_id in (OrderStatusId.CANCELLED, OrderStatusId.FAILED): status_name = OrderStatusId(info.status_id).name - raise EToroError( - f"Order {order_id} was {status_name}: {info.error_message or 'unknown reason'}" - ) + raise EToroError(f"Order {order_id} was {status_name}: {info.error_message or 'unknown reason'}") except EToroError: raise except Exception: diff --git a/etoropy/ws/client.py b/etoropy/ws/client.py index a0b61ba..471d132 100644 --- a/etoropy/ws/client.py +++ b/etoropy/ws/client.py @@ -22,7 +22,7 @@ ) from ..errors.exceptions import EToroAuthError, EToroWebSocketError from ..models.websocket import WsEnvelope -from .message_parser import parse_messages +from .message_parser import ParsedInstrumentRate, ParsedPrivateEvent, parse_messages from .subscription import WsSubscriptionTracker logger = logging.getLogger("etoropy") @@ -103,6 +103,7 @@ def off(self, event: str, handler: EventHandler) -> WsClient: def once(self, event: str, handler: EventHandler) -> WsClient: """Register *handler* for *event*, then auto-unregister after the first call.""" + def wrapper(*args: Any, **kwargs: Any) -> Any: self.off(event, wrapper) return handler(*args, **kwargs) @@ -271,9 +272,9 @@ def _handle_message(self, data: str) -> None: parsed = parse_messages(envelope) for msg in parsed: - if msg.type == "instrument:rate": + if msg.type == "instrument:rate" and isinstance(msg.data, ParsedInstrumentRate): self._emit("instrument:rate", msg.data.instrument_id, msg.data.rate) - elif msg.type == "private:event": + elif msg.type == "private:event" and isinstance(msg.data, ParsedPrivateEvent): self._emit("private:event", msg.data.event) except Exception: logger.error("Failed to parse WebSocket message: %s", data[:200]) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5ca111a..de6a1a6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,5 +1,3 @@ -import os - import pytest from etoropy.config.constants import ( diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index a0dc62f..d5682dd 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,5 +1,3 @@ -import pytest - from etoropy.errors.exceptions import ( EToroApiError, EToroAuthError, diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index c0a5c42..2437279 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,13 +1,10 @@ from etoropy.models.market_data import ( InstrumentRate, - InstrumentSearchItem, InstrumentSearchResponse, - LiveRatesResponse, ) from etoropy.models.trading import ( ClosePositionRequest, MarketOrderByAmountRequest, - OrderForOpen, OrderForOpenResponse, PendingOrder, Position, diff --git a/tests/unit/test_rate_limiter.py b/tests/unit/test_rate_limiter.py index 2ebfac7..9c03d48 100644 --- a/tests/unit/test_rate_limiter.py +++ b/tests/unit/test_rate_limiter.py @@ -1,5 +1,4 @@ import asyncio -import time import pytest diff --git a/tests/unit/test_ws_message_parser.py b/tests/unit/test_ws_message_parser.py index 976dcd0..fb7c29f 100644 --- a/tests/unit/test_ws_message_parser.py +++ b/tests/unit/test_ws_message_parser.py @@ -8,7 +8,10 @@ def test_parse_instrument_rate() -> None: "messages": [ { "topic": "instrument:1001", - "content": '{"Ask": 150.25, "Bid": 150.10, "LastExecution": 150.15, "Date": "2024-01-01", "PriceRateID": 123}', + "content": ( + '{"Ask": 150.25, "Bid": 150.10, "LastExecution": 150.15,' + ' "Date": "2024-01-01", "PriceRateID": 123}' + ), "id": "msg-1", "type": "rate", } @@ -29,7 +32,14 @@ def test_parse_private_event() -> None: "messages": [ { "topic": "private", - "content": '{"OrderID": 100, "OrderType": 1, "StatusID": 3, "InstrumentID": 1001, "CID": 200, "RequestedUnits": 1.0, "ExecutedUnits": 1.0, "NetProfit": 10.0, "CloseReason": "", "OpenDateTime": "2024-01-01", "RequestOccurred": "2024-01-01"}', + "content": ( + '{"OrderID": 100, "OrderType": 1, "StatusID": 3,' + ' "InstrumentID": 1001, "CID": 200,' + ' "RequestedUnits": 1.0, "ExecutedUnits": 1.0,' + ' "NetProfit": 10.0, "CloseReason": "",' + ' "OpenDateTime": "2024-01-01",' + ' "RequestOccurred": "2024-01-01"}' + ), "id": "msg-2", "type": "event", } @@ -62,7 +72,11 @@ def test_parse_unknown_topic() -> None: def test_parse_envelope_from_string() -> None: - raw = '{"messages": [{"topic": "instrument:999", "content": "{\\"Ask\\": 50.0, \\"Bid\\": 49.0}", "id": "x", "type": "rate"}]}' + raw = ( + '{"messages": [{"topic": "instrument:999",' + ' "content": "{\\"Ask\\": 50.0, \\"Bid\\": 49.0}",' + ' "id": "x", "type": "rate"}]}' + ) envelope = parse_envelope(raw) assert len(envelope.messages) == 1 assert envelope.messages[0].topic == "instrument:999" From 299d8e5e0ae7b12eae73b77f7826bad8991d5f42 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 19:46:47 +0100 Subject: [PATCH 2/4] ci: publish dev builds to TestPyPI from develop After lint+test pass on develop pushes, stamps the version as .dev (PEP 440) and publishes to TestPyPI via OIDC trusted publishing. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbb353e..0467d40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,38 @@ jobs: - run: uv sync --frozen --all-extras - run: uv run pytest + + publish-dev: + name: Publish dev build + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: [lint, test] + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set dev version + run: | + base=$(python -c " + import re, pathlib + text = pathlib.Path('pyproject.toml').read_text() + print(re.search(r'version\s*=\s*\"(.+?)\"', text).group(1)) + ") + echo "DEV_VERSION=${base}.dev${{ github.run_number }}" + sed -i "s/^version = \".*\"/version = \"${base}.dev${{ github.run_number }}\"/" pyproject.toml + + - run: uv build + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ From e4059e08bcef0f40d854d60c616aad46c6a398c8 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 20:03:34 +0100 Subject: [PATCH 3/4] chore: update IDE config and README warning admonition [skip ci] --- .idea/misc.xml | 2 +- README.md | 3 ++- etoropy.iml | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 639900d..ea5c012 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 83f9666..83945eb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Python SDK for the [eToro Public API](https://www.etoro.com/). Async-first, fully typed, built for algo trading. -> **Alpha software** -- This package is under active development and its API may +> [!WARNING] +> **Alpha software** ⚠️ This package is under active development and its API may > change without notice. Use at your own risk. The authors accept no > responsibility for any financial losses incurred through the use of this > software. Always test thoroughly in demo mode before trading with real funds. diff --git a/etoropy.iml b/etoropy.iml index 9a5cfce..b8da07f 100644 --- a/etoropy.iml +++ b/etoropy.iml @@ -2,7 +2,9 @@ - + + + \ No newline at end of file From 9af99fd1b5255d169c6293c5d2db81fc12ae06f6 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 20:08:44 +0100 Subject: [PATCH 4/4] ci: trigger PR checks