Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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/
42 changes: 42 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,3 @@ coverage.xml
.mypy_cache/
.ruff_cache/

# uv
uv.lock
2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion etoropy.iml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
<module type="GENERAL_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
4 changes: 2 additions & 2 deletions etoropy/http/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down
4 changes: 0 additions & 4 deletions etoropy/models/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -77,15 +75,13 @@ class UserPortfolio(BaseModel):
positions: list[Any] = []



class CopierInfo(BaseModel):
model_config = {"extra": "allow"}

user_id: int = Field(alias="userId")
copiers_count: int = Field(0, alias="copiersCount")



class CuratedList(BaseModel):
model_config = {"extra": "allow"}

Expand Down
4 changes: 1 addition & 3 deletions etoropy/models/market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 0 additions & 7 deletions etoropy/models/trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,7 +75,6 @@ class OrderForCloseResponse(BaseModel):
token: str



class OrderPositionInfo(BaseModel):
model_config = {"extra": "allow"}

Expand Down Expand Up @@ -109,7 +107,6 @@ class OrderForOpenInfoResponse(BaseModel):
positions: list[OrderPositionInfo] = []



class Position(BaseModel):
model_config = {"extra": "allow"}

Expand Down Expand Up @@ -150,7 +147,6 @@ class Position(BaseModel):
lot_count: float = Field(0.0, alias="lotCount")



class PendingOrder(BaseModel):
model_config = {"extra": "allow"}

Expand All @@ -169,7 +165,6 @@ class PendingOrder(BaseModel):
execution_type: int = Field(0, alias="executionType")



class Mirror(BaseModel):
model_config = {"extra": "allow"}

Expand All @@ -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
Expand All @@ -215,7 +209,6 @@ class PnlResponse(BaseModel):
client_portfolio: ClientPortfolio = Field(alias="clientPortfolio")



class TradeHistoryParams(BaseModel):
min_date: str
page: int | None = None
Expand Down
2 changes: 0 additions & 2 deletions etoropy/models/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ class WsUnsubscribeOperation(BaseModel):
data: dict[str, list[str]]



class WsMessage(BaseModel):
topic: str
content: str
Expand All @@ -35,7 +34,6 @@ class WsEnvelope(BaseModel):
messages: list[WsMessage] = []



class WsInstrumentRate(BaseModel):
ask: float = Field(alias="Ask")
bid: float = Field(alias="Bid")
Expand Down
6 changes: 3 additions & 3 deletions etoropy/rest/discovery.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from __future__ import annotations

from typing import Any
from typing import Any, cast

from ..config.constants import API_PREFIX
from ._base import BaseRestClient


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"))
1 change: 1 addition & 0 deletions etoropy/rest/market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion etoropy/rest/trading_execution.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions etoropy/rest/watchlists.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
from __future__ import annotations

from typing import Any
from typing import Any, cast

from ..config.constants import API_PREFIX
from ._base import BaseRestClient


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}
Expand Down Expand Up @@ -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}")
Loading
Loading