diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0467d40
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,88 @@
+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
+
+ 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/
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/.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
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"