fundedops 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fundedops/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """fundedops — the official Python SDK for the FundedOps API.
2
+
3
+ This package ships the contract layer the HTTP and streaming clients build on:
4
+
5
+ * :mod:`fundedops.models` — pydantic v2 models that mirror the camelCase wire
6
+ format exactly (snake_case attributes, camelCase aliases).
7
+ * :mod:`fundedops.errors` — a typed exception hierarchy mirroring the API's
8
+ MetaApi-style error envelope.
9
+ * :class:`FundedOps` / :class:`AsyncFundedOps` — the synchronous and
10
+ asynchronous REST + streaming clients (first-class equals, D-17).
11
+ * :class:`Stream` / :class:`AsyncStream` — the WS event iterators returned by
12
+ ``client.stream(...)``.
13
+ """
14
+
15
+ from ._async_client import AsyncFundedOps
16
+ from ._base import DEFAULT_BASE_URL
17
+ from ._client import FundedOps
18
+ from ._streaming import AsyncStream, Stream, StreamClosed
19
+ from .errors import (
20
+ ConflictError,
21
+ ForbiddenError,
22
+ FundedOpsError,
23
+ InternalError,
24
+ NotFoundError,
25
+ PaymentRequiredError,
26
+ RateLimitedError,
27
+ UnauthorizedError,
28
+ ValidationError,
29
+ raise_for_envelope,
30
+ )
31
+ from .models import (
32
+ Account,
33
+ AccountInformation,
34
+ AccountInformationEvent,
35
+ AccountInformationUpdatedEvent,
36
+ Deal,
37
+ DealAddedEvent,
38
+ DealsPage,
39
+ ErrorEvent,
40
+ Order,
41
+ OrdersEvent,
42
+ OrdersPage,
43
+ OrdersUpdatedEvent,
44
+ Page,
45
+ PingEvent,
46
+ Position,
47
+ PositionsEvent,
48
+ PositionsPage,
49
+ PositionsUpdatedEvent,
50
+ StatusEvent,
51
+ SyncFinishedEvent,
52
+ SyncStartedEvent,
53
+ WSEvent,
54
+ parse_event,
55
+ )
56
+
57
+ __version__ = "0.1.0"
58
+
59
+ __all__ = [
60
+ "__version__",
61
+ "DEFAULT_BASE_URL",
62
+ # Clients
63
+ "FundedOps",
64
+ "AsyncFundedOps",
65
+ # Streaming
66
+ "Stream",
67
+ "AsyncStream",
68
+ "StreamClosed",
69
+ # Resources
70
+ "Account",
71
+ "AccountInformation",
72
+ "Position",
73
+ "Order",
74
+ "Deal",
75
+ "Page",
76
+ "PositionsPage",
77
+ "OrdersPage",
78
+ "DealsPage",
79
+ # WebSocket events
80
+ "parse_event",
81
+ "WSEvent",
82
+ "SyncStartedEvent",
83
+ "SyncFinishedEvent",
84
+ "AccountInformationEvent",
85
+ "AccountInformationUpdatedEvent",
86
+ "PositionsEvent",
87
+ "PositionsUpdatedEvent",
88
+ "OrdersEvent",
89
+ "OrdersUpdatedEvent",
90
+ "DealAddedEvent",
91
+ "StatusEvent",
92
+ "PingEvent",
93
+ "ErrorEvent",
94
+ # Errors
95
+ "raise_for_envelope",
96
+ "FundedOpsError",
97
+ "ValidationError",
98
+ "UnauthorizedError",
99
+ "PaymentRequiredError",
100
+ "ForbiddenError",
101
+ "NotFoundError",
102
+ "ConflictError",
103
+ "RateLimitedError",
104
+ "InternalError",
105
+ ]
@@ -0,0 +1,222 @@
1
+ """Asynchronous FundedOps client over ``httpx.AsyncClient``.
2
+
3
+ A method-for-method mirror of :mod:`fundedops._client` — same names, same
4
+ signatures, awaitable. D-17 makes the async client a first-class equal, so any
5
+ method present in one and absent from the other is a defect; ``test_async_client``
6
+ introspects both classes and asserts the public method sets are identical. All
7
+ request/param/body/parse logic is shared through :mod:`fundedops._base`, so this
8
+ module only adds ``await`` around the network boundary.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from . import _base
20
+ from ._base import UNSET, DEFAULT_BASE_URL, DEFAULT_TIMEOUT
21
+ from .errors import raise_for_envelope
22
+ from .models import Account, AccountInformation, DealsPage, OrdersPage, PositionsPage
23
+
24
+
25
+ class _AsyncAccounts:
26
+ """The async ``client.accounts`` namespace — mirrors ``_client._Accounts``."""
27
+
28
+ def __init__(self, client: "AsyncFundedOps") -> None:
29
+ self._client = client
30
+
31
+ # --- lifecycle ---------------------------------------------------------
32
+
33
+ async def list(
34
+ self,
35
+ offset: int = 0,
36
+ limit: int | None = None,
37
+ state: str | None = None,
38
+ tags: str | None = None,
39
+ ) -> list[Account]:
40
+ data = await self._client._request(
41
+ _base.list_accounts_spec(offset, limit, state, tags)
42
+ )
43
+ return _base.parse_accounts(data)
44
+
45
+ async def create(
46
+ self,
47
+ login: str,
48
+ password: str,
49
+ server: str,
50
+ platform: str,
51
+ name: str | None = None,
52
+ tags: list[str] | None = None,
53
+ metadata: dict | None = None,
54
+ ) -> Account:
55
+ data = await self._client._request(
56
+ _base.create_account_spec(
57
+ login, password, server, platform, name, tags, metadata
58
+ )
59
+ )
60
+ return _base.parse_account(data)
61
+
62
+ async def get(self, account_id: str) -> Account:
63
+ return _base.parse_account(
64
+ await self._client._request(_base.get_account_spec(account_id))
65
+ )
66
+
67
+ async def update(
68
+ self,
69
+ account_id: str,
70
+ name: Any = UNSET,
71
+ tags: Any = UNSET,
72
+ metadata: Any = UNSET,
73
+ ) -> Account:
74
+ return _base.parse_account(
75
+ await self._client._request(
76
+ _base.update_account_spec(account_id, name, tags, metadata)
77
+ )
78
+ )
79
+
80
+ async def delete(self, account_id: str) -> Account:
81
+ return _base.parse_account(
82
+ await self._client._request(_base.delete_account_spec(account_id))
83
+ )
84
+
85
+ async def deploy(self, account_id: str) -> Account:
86
+ return _base.parse_account(
87
+ await self._client._request(_base.deploy_spec(account_id))
88
+ )
89
+
90
+ async def undeploy(self, account_id: str) -> Account:
91
+ return _base.parse_account(
92
+ await self._client._request(_base.undeploy_spec(account_id))
93
+ )
94
+
95
+ async def redeploy(self, account_id: str) -> Account:
96
+ return _base.parse_account(
97
+ await self._client._request(_base.redeploy_spec(account_id))
98
+ )
99
+
100
+ # --- reads -------------------------------------------------------------
101
+
102
+ async def account_information(self, account_id: str) -> AccountInformation:
103
+ return _base.parse_account_information(
104
+ await self._client._request(_base.account_information_spec(account_id))
105
+ )
106
+
107
+ async def positions(self, account_id: str) -> PositionsPage:
108
+ return _base.parse_positions_page(
109
+ await self._client._request(_base.positions_spec(account_id))
110
+ )
111
+
112
+ async def pending_orders(self, account_id: str) -> OrdersPage:
113
+ return _base.parse_orders_page(
114
+ await self._client._request(_base.pending_orders_spec(account_id))
115
+ )
116
+
117
+ async def history_deals(
118
+ self,
119
+ account_id: str,
120
+ start_time: str | None = None,
121
+ end_time: str | None = None,
122
+ ticket: str | None = None,
123
+ position: str | None = None,
124
+ offset: int = 0,
125
+ limit: int | None = None,
126
+ ) -> DealsPage:
127
+ return _base.parse_deals_page(
128
+ await self._client._request(
129
+ _base.history_deals_spec(
130
+ account_id, start_time, end_time, ticket, position, offset, limit
131
+ )
132
+ )
133
+ )
134
+
135
+ async def history_orders(
136
+ self,
137
+ account_id: str,
138
+ start_time: str | None = None,
139
+ end_time: str | None = None,
140
+ ticket: str | None = None,
141
+ position: str | None = None,
142
+ offset: int = 0,
143
+ limit: int | None = None,
144
+ ) -> OrdersPage:
145
+ return _base.parse_orders_page(
146
+ await self._client._request(
147
+ _base.history_orders_spec(
148
+ account_id, start_time, end_time, ticket, position, offset, limit
149
+ )
150
+ )
151
+ )
152
+
153
+
154
+ class AsyncFundedOps:
155
+ """Asynchronous FundedOps API client — the awaitable mirror of
156
+ :class:`fundedops.FundedOps`. Same constructor, same method surface."""
157
+
158
+ def __init__(
159
+ self,
160
+ api_key: str,
161
+ base_url: str = DEFAULT_BASE_URL,
162
+ timeout: float = DEFAULT_TIMEOUT,
163
+ transport: httpx.AsyncBaseTransport | None = None,
164
+ ) -> None:
165
+ self._api_key = api_key
166
+ self._base_url = base_url
167
+ self._http = httpx.AsyncClient(
168
+ base_url=base_url,
169
+ headers=_base.build_headers(api_key),
170
+ timeout=timeout,
171
+ transport=transport,
172
+ )
173
+ self.accounts = _AsyncAccounts(self)
174
+
175
+ async def _request(self, spec: _base.RequestSpec) -> Any:
176
+ """Await one RequestSpec, translate any error envelope, return the decoded
177
+ JSON body — the single point every response flows through
178
+ ``raise_for_envelope`` (mirrors the sync client)."""
179
+ response = await self._http.request(
180
+ spec.method, spec.path, params=spec.params, json=spec.json
181
+ )
182
+ raise_for_envelope(response)
183
+ return response.json()
184
+
185
+ async def wait_until_connected(
186
+ self, account_id: str, timeout: float, interval: float = 2.0
187
+ ) -> Account:
188
+ """Await polling of ``GET /v1/accounts/{id}`` until connected (D-16).
189
+
190
+ Raises :class:`TimeoutError` past ``timeout`` (T-19-16 — mandatory bound).
191
+ ``interval=0`` keeps tests free of real awaits between polls."""
192
+ deadline = time.monotonic() + timeout
193
+ while True:
194
+ account = await self.accounts.get(account_id)
195
+ if account.connection_status == "connected":
196
+ return account
197
+ if time.monotonic() >= deadline:
198
+ raise TimeoutError(
199
+ f"account {account_id} did not connect within {timeout}s "
200
+ f"(last connectionStatus={account.connection_status!r})"
201
+ )
202
+ if interval:
203
+ await asyncio.sleep(interval)
204
+
205
+ def stream(self, account_ids: list[str]):
206
+ """Open a WS stream and async-iterate typed events for ``account_ids``.
207
+
208
+ Returns an async-iterable :class:`fundedops._streaming.AsyncStream`
209
+ (imported lazily so the REST client carries no import-time websockets
210
+ dependency)."""
211
+ from ._streaming import AsyncStream
212
+
213
+ return AsyncStream(self._base_url, self._api_key, account_ids)
214
+
215
+ async def aclose(self) -> None:
216
+ await self._http.aclose()
217
+
218
+ async def __aenter__(self) -> "AsyncFundedOps":
219
+ return self
220
+
221
+ async def __aexit__(self, *exc: object) -> None:
222
+ await self.aclose()
fundedops/_base.py ADDED
@@ -0,0 +1,230 @@
1
+ """Shared SDK internals — the single source of truth both clients build on.
2
+
3
+ The synchronous (:mod:`fundedops._client`) and asynchronous
4
+ (:mod:`fundedops._async_client`) clients differ ONLY in ``await``: every path,
5
+ query param, request body, and response-parse decision lives here so the two
6
+ surfaces cannot drift (D-17 requires them to be first-class equals). Each public
7
+ client method maps to one :func:`RequestSpec` builder below plus one parser.
8
+
9
+ Security: the ``Authorization: Bearer fo_...`` header is built here (T-19-15 —
10
+ the default base URL is hardcoded ``https://`` so the key only ever leaves over
11
+ TLS unless the caller explicitly overrides ``base_url``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, NamedTuple
17
+
18
+ from .models import (
19
+ Account,
20
+ AccountInformation,
21
+ DealsPage,
22
+ OrdersPage,
23
+ PositionsPage,
24
+ )
25
+
26
+ DEFAULT_BASE_URL = "https://api.fundedops.com"
27
+ DEFAULT_TIMEOUT = 30.0
28
+
29
+ # The mounted public sub-app lives under /v1 (backend app.mount("/v1", public_app)).
30
+ _V1 = "/v1"
31
+
32
+ # Sentinel distinguishing "field omitted from a PATCH" from an explicit ``None``
33
+ # / empty value the caller wants to send (mirrors the backend's
34
+ # model_fields_set / exclude_unset semantics in UpdateAccountRequest).
35
+ UNSET: Any = object()
36
+
37
+
38
+ class RequestSpec(NamedTuple):
39
+ """A pure description of one HTTP call. The clients turn it into a real
40
+ request; nothing here touches the network, so it is trivially testable and
41
+ identical across sync and async."""
42
+
43
+ method: str
44
+ path: str
45
+ params: dict[str, Any] | None = None
46
+ json: dict[str, Any] | None = None
47
+
48
+
49
+ def build_headers(api_key: str) -> dict[str, str]:
50
+ """The confirmed public auth scheme (backend auth.py::_extract_key):
51
+ ``Authorization: Bearer fo_...``."""
52
+ return {"Authorization": f"Bearer {api_key}"}
53
+
54
+
55
+ def _clean(mapping: dict[str, Any]) -> dict[str, Any]:
56
+ """Drop keys whose value is ``None`` or the :data:`UNSET` sentinel — used for
57
+ optional query params and optional create fields."""
58
+ return {k: v for k, v in mapping.items() if v is not None and v is not UNSET}
59
+
60
+
61
+ def ws_stream_url(base_url: str, api_key: str) -> str:
62
+ """The ``wss://.../v1/stream?api_key=`` URL (backend ws_auth.py query auth).
63
+
64
+ http(s) → ws(s); the key rides the query string, so this string must NEVER be
65
+ logged (T-19-07 — enforced by the grep gate in the streaming module)."""
66
+ base = base_url.rstrip("/")
67
+ if base.startswith("https://"):
68
+ base = "wss://" + base[len("https://") :]
69
+ elif base.startswith("http://"):
70
+ base = "ws://" + base[len("http://") :]
71
+ return f"{base}{_V1}/stream?api_key={api_key}"
72
+
73
+
74
+ # --- REST request-spec builders (one per public method) ---------------------
75
+
76
+
77
+ def list_accounts_spec(
78
+ offset: int = 0,
79
+ limit: int | None = None,
80
+ state: str | None = None,
81
+ tags: str | None = None,
82
+ ) -> RequestSpec:
83
+ params = _clean({"offset": offset, "limit": limit, "state": state, "tags": tags})
84
+ return RequestSpec("GET", f"{_V1}/accounts", params=params)
85
+
86
+
87
+ def create_account_spec(
88
+ login: str,
89
+ password: str,
90
+ server: str,
91
+ platform: str,
92
+ name: str | None = None,
93
+ tags: list[str] | None = None,
94
+ metadata: dict | None = None,
95
+ ) -> RequestSpec:
96
+ body = _clean(
97
+ {
98
+ "login": login,
99
+ "password": password,
100
+ "server": server,
101
+ "platform": platform,
102
+ "name": name,
103
+ "tags": tags,
104
+ "metadata": metadata,
105
+ }
106
+ )
107
+ return RequestSpec("POST", f"{_V1}/accounts", json=body)
108
+
109
+
110
+ def get_account_spec(account_id: str) -> RequestSpec:
111
+ return RequestSpec("GET", f"{_V1}/accounts/{account_id}")
112
+
113
+
114
+ def update_account_spec(
115
+ account_id: str,
116
+ name: Any = UNSET,
117
+ tags: Any = UNSET,
118
+ metadata: Any = UNSET,
119
+ ) -> RequestSpec:
120
+ # Send only the fields the caller actually set (UNSET → omit); an explicit
121
+ # ``None``/``[]``/``{}`` is a real value and is forwarded.
122
+ body = {
123
+ k: v
124
+ for k, v in {"name": name, "tags": tags, "metadata": metadata}.items()
125
+ if v is not UNSET
126
+ }
127
+ return RequestSpec("PATCH", f"{_V1}/accounts/{account_id}", json=body)
128
+
129
+
130
+ def delete_account_spec(account_id: str) -> RequestSpec:
131
+ return RequestSpec("DELETE", f"{_V1}/accounts/{account_id}")
132
+
133
+
134
+ def deploy_spec(account_id: str) -> RequestSpec:
135
+ return RequestSpec("POST", f"{_V1}/accounts/{account_id}/deploy")
136
+
137
+
138
+ def undeploy_spec(account_id: str) -> RequestSpec:
139
+ return RequestSpec("POST", f"{_V1}/accounts/{account_id}/undeploy")
140
+
141
+
142
+ def redeploy_spec(account_id: str) -> RequestSpec:
143
+ return RequestSpec("POST", f"{_V1}/accounts/{account_id}/redeploy")
144
+
145
+
146
+ def account_information_spec(account_id: str) -> RequestSpec:
147
+ return RequestSpec("GET", f"{_V1}/accounts/{account_id}/account-information")
148
+
149
+
150
+ def positions_spec(account_id: str) -> RequestSpec:
151
+ return RequestSpec("GET", f"{_V1}/accounts/{account_id}/positions")
152
+
153
+
154
+ def pending_orders_spec(account_id: str) -> RequestSpec:
155
+ return RequestSpec("GET", f"{_V1}/accounts/{account_id}/orders")
156
+
157
+
158
+ def history_deals_spec(
159
+ account_id: str,
160
+ start_time: str | None = None,
161
+ end_time: str | None = None,
162
+ ticket: str | None = None,
163
+ position: str | None = None,
164
+ offset: int = 0,
165
+ limit: int | None = None,
166
+ ) -> RequestSpec:
167
+ params = _clean(
168
+ {
169
+ "start_time": start_time,
170
+ "end_time": end_time,
171
+ "ticket": ticket,
172
+ "position": position,
173
+ "offset": offset if offset else None,
174
+ "limit": limit,
175
+ }
176
+ )
177
+ return RequestSpec(
178
+ "GET", f"{_V1}/accounts/{account_id}/history-deals", params=params
179
+ )
180
+
181
+
182
+ def history_orders_spec(
183
+ account_id: str,
184
+ start_time: str | None = None,
185
+ end_time: str | None = None,
186
+ ticket: str | None = None,
187
+ position: str | None = None,
188
+ offset: int = 0,
189
+ limit: int | None = None,
190
+ ) -> RequestSpec:
191
+ params = _clean(
192
+ {
193
+ "start_time": start_time,
194
+ "end_time": end_time,
195
+ "ticket": ticket,
196
+ "position": position,
197
+ "offset": offset if offset else None,
198
+ "limit": limit,
199
+ }
200
+ )
201
+ return RequestSpec(
202
+ "GET", f"{_V1}/accounts/{account_id}/history-orders", params=params
203
+ )
204
+
205
+
206
+ # --- Response parsers (shared so sync/async return identical types) ---------
207
+
208
+
209
+ def parse_account(data: Any) -> Account:
210
+ return Account.model_validate(data)
211
+
212
+
213
+ def parse_accounts(data: Any) -> list[Account]:
214
+ return [Account.model_validate(row) for row in data]
215
+
216
+
217
+ def parse_account_information(data: Any) -> AccountInformation:
218
+ return AccountInformation.model_validate(data)
219
+
220
+
221
+ def parse_positions_page(data: Any) -> PositionsPage:
222
+ return PositionsPage.model_validate(data)
223
+
224
+
225
+ def parse_orders_page(data: Any) -> OrdersPage:
226
+ return OrdersPage.model_validate(data)
227
+
228
+
229
+ def parse_deals_page(data: Any) -> DealsPage:
230
+ return DealsPage.model_validate(data)