fxsocket 0.1__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.
fxsocket/__init__.py ADDED
@@ -0,0 +1,134 @@
1
+ """FxSocket Python SDK.
2
+
3
+ Account management today (the v1 API); per-account terminal REST and
4
+ WebSocket streaming for MT4/MT5 land in the next milestones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import errors
10
+ from ._version import __version__
11
+ from .client import AsyncClient, Client
12
+ from .enums import (
13
+ DealEntry,
14
+ HealthStatus,
15
+ OrderKind,
16
+ OrderOperation,
17
+ Platform,
18
+ Timeframe,
19
+ TradingStatus,
20
+ )
21
+ from .errors import (
22
+ AccountCapError,
23
+ AuthError,
24
+ ConnectFailedError,
25
+ DuplicateAccountError,
26
+ FxSocketError,
27
+ NoSubscriptionError,
28
+ NotFoundError,
29
+ RateLimitError,
30
+ StreamError,
31
+ TerminalNotReadyError,
32
+ TerminalTimeoutError,
33
+ UnsupportedOnPlatformError,
34
+ ValidationError,
35
+ )
36
+ from .models import (
37
+ Account,
38
+ AccountInfo,
39
+ AccountSummary,
40
+ Candle,
41
+ Health,
42
+ HealthChecks,
43
+ HistoryTrade,
44
+ MarginCalc,
45
+ OpenedOrder,
46
+ OrderResult,
47
+ PositionTrade,
48
+ ProfitCalc,
49
+ Quote,
50
+ ServerTimezone,
51
+ SymbolInfo,
52
+ TerminalStatusData,
53
+ TradeEventData,
54
+ )
55
+ from .terminal import (
56
+ AccountUpdate,
57
+ AsyncStream,
58
+ AsyncTerminalClient,
59
+ Bar,
60
+ PositionsUpdate,
61
+ Stream,
62
+ StreamErrorEvent,
63
+ StreamEvent,
64
+ StreamWarning,
65
+ Subscribed,
66
+ Subscriptions,
67
+ TerminalClient,
68
+ TerminalUpdate,
69
+ Tick,
70
+ TradeUpdate,
71
+ UnknownEvent,
72
+ Unsubscribed,
73
+ )
74
+
75
+ __all__ = [
76
+ "__version__",
77
+ "Client",
78
+ "AsyncClient",
79
+ "TerminalClient",
80
+ "AsyncTerminalClient",
81
+ "AsyncStream",
82
+ "Stream",
83
+ "StreamEvent",
84
+ "Tick",
85
+ "Bar",
86
+ "AccountUpdate",
87
+ "PositionsUpdate",
88
+ "TradeUpdate",
89
+ "TerminalUpdate",
90
+ "StreamWarning",
91
+ "Subscribed",
92
+ "Unsubscribed",
93
+ "StreamErrorEvent",
94
+ "Subscriptions",
95
+ "UnknownEvent",
96
+ "TradeEventData",
97
+ "TerminalStatusData",
98
+ "Account",
99
+ "AccountSummary",
100
+ "AccountInfo",
101
+ "OpenedOrder",
102
+ "HistoryTrade",
103
+ "PositionTrade",
104
+ "ServerTimezone",
105
+ "Quote",
106
+ "SymbolInfo",
107
+ "Candle",
108
+ "OrderResult",
109
+ "MarginCalc",
110
+ "ProfitCalc",
111
+ "Health",
112
+ "HealthChecks",
113
+ "Platform",
114
+ "TradingStatus",
115
+ "OrderOperation",
116
+ "OrderKind",
117
+ "DealEntry",
118
+ "HealthStatus",
119
+ "Timeframe",
120
+ "errors",
121
+ "FxSocketError",
122
+ "AuthError",
123
+ "RateLimitError",
124
+ "ValidationError",
125
+ "NotFoundError",
126
+ "AccountCapError",
127
+ "NoSubscriptionError",
128
+ "DuplicateAccountError",
129
+ "ConnectFailedError",
130
+ "TerminalNotReadyError",
131
+ "TerminalTimeoutError",
132
+ "UnsupportedOnPlatformError",
133
+ "StreamError",
134
+ ]
fxsocket/_http.py ADDED
@@ -0,0 +1,68 @@
1
+ """Thin transport helpers shared by the sync and async clients.
2
+
3
+ The response handling is identical for both, so it lives in one place; the
4
+ sync/async split is only in who awaits the network call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._version import __version__
14
+ from .errors import error_from_response
15
+
16
+
17
+ def auth_headers(api_key: str) -> dict[str, str]:
18
+ """Default headers carrying the API key for every request."""
19
+ return {
20
+ "X-API-Key": api_key,
21
+ "User-Agent": f"fxsocket-python/{__version__}",
22
+ "Accept": "application/json",
23
+ }
24
+
25
+
26
+ def process_response(resp: httpx.Response) -> Any:
27
+ """Return the decoded JSON body, or raise a typed error on failure."""
28
+ if resp.is_success:
29
+ if resp.status_code == 204 or not resp.content:
30
+ return None
31
+ return resp.json()
32
+ raise error_from_response(resp)
33
+
34
+
35
+ class SyncHTTP:
36
+ """Synchronous request wrapper over an :class:`httpx.Client`."""
37
+
38
+ def __init__(self, client: httpx.Client) -> None:
39
+ self._client = client
40
+
41
+ def request(
42
+ self,
43
+ method: str,
44
+ path: str,
45
+ *,
46
+ params: dict[str, Any] | None = None,
47
+ json: Any | None = None,
48
+ ) -> Any:
49
+ resp = self._client.request(method, path, params=params, json=json)
50
+ return process_response(resp)
51
+
52
+
53
+ class AsyncHTTP:
54
+ """Asynchronous request wrapper over an :class:`httpx.AsyncClient`."""
55
+
56
+ def __init__(self, client: httpx.AsyncClient) -> None:
57
+ self._client = client
58
+
59
+ async def request(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ *,
64
+ params: dict[str, Any] | None = None,
65
+ json: Any | None = None,
66
+ ) -> Any:
67
+ resp = await self._client.request(method, path, params=params, json=json)
68
+ return process_response(resp)
fxsocket/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1"
fxsocket/client.py ADDED
@@ -0,0 +1,254 @@
1
+ """Top-level entry points: :class:`Client` (sync) and :class:`AsyncClient`.
2
+
3
+ Both expose ``.accounts`` (the management API), ``.terminal(account)`` (the
4
+ per-account terminal REST client), and ``.stream(account)`` (WebSocket
5
+ streaming — a sync :class:`~fxsocket.Stream` or an async
6
+ :class:`~fxsocket.AsyncStream`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import httpx
14
+
15
+ from ._http import AsyncHTTP, SyncHTTP, auth_headers
16
+ from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, ENV_API_KEY
17
+ from .errors import AuthError, TerminalNotReadyError
18
+ from .management import Accounts, AsyncAccounts
19
+ from .models import Account
20
+ from .terminal.client import AsyncTerminalClient, TerminalClient
21
+ from .terminal.stream import AsyncStream, Stream
22
+
23
+
24
+ def _resolve_api_key(api_key: str | None) -> str:
25
+ key = api_key or os.environ.get(ENV_API_KEY)
26
+ if not key:
27
+ raise AuthError(
28
+ "No API key. Pass api_key=... or set the "
29
+ f"{ENV_API_KEY} environment variable."
30
+ )
31
+ return key
32
+
33
+
34
+ class Client:
35
+ """Synchronous FxSocket client.
36
+
37
+ Usage::
38
+
39
+ from fxsocket import Client
40
+
41
+ with Client(api_key="fxs_live_...") as fx:
42
+ for acct in fx.accounts.list():
43
+ print(acct.nickname, acct.status)
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str | None = None,
49
+ *,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ timeout: float = DEFAULT_TIMEOUT,
52
+ verify_terminal_tls: bool = True,
53
+ ) -> None:
54
+ self._api_key = _resolve_api_key(api_key)
55
+ self._timeout = timeout
56
+ #: Verify TLS for terminal calls. Private-hosting droplets use a
57
+ #: self-signed cert; set False (or pass a CA) to reach them.
58
+ self.verify_terminal_tls = verify_terminal_tls
59
+ self._http_client = httpx.Client(
60
+ base_url=base_url,
61
+ headers=auth_headers(self._api_key),
62
+ timeout=timeout,
63
+ )
64
+ #: Account management (the v1 API).
65
+ self.accounts = Accounts(SyncHTTP(self._http_client))
66
+ self._terminals: dict[tuple[str, str], TerminalClient] = {}
67
+
68
+ def terminal(
69
+ self,
70
+ account: Account,
71
+ *,
72
+ verify: bool | None = None,
73
+ timeout: float | None = None,
74
+ ) -> TerminalClient:
75
+ """Return a REST client bound to ``account``'s terminal.
76
+
77
+ Resolves the endpoint from ``account.rest_url`` (shared pod or private
78
+ droplet). Raises :class:`TerminalNotReadyError` when the account has no
79
+ terminal yet (still provisioning, or bridge-only). Clients are cached
80
+ per endpoint and closed by :meth:`close`. Pass ``verify=False`` for a
81
+ private droplet's self-signed certificate.
82
+ """
83
+ if not account.rest_url:
84
+ raise TerminalNotReadyError(
85
+ f"Account {account.id} has no terminal endpoint yet "
86
+ "(still provisioning, or bridge-only)."
87
+ )
88
+ key = (account.rest_url, account.platform.value)
89
+ cached = self._terminals.get(key)
90
+ if cached is None:
91
+ cached = TerminalClient(
92
+ base_url=account.rest_url,
93
+ api_key=self._api_key,
94
+ platform=account.platform,
95
+ verify=self.verify_terminal_tls if verify is None else verify,
96
+ timeout=self._timeout if timeout is None else timeout,
97
+ )
98
+ self._terminals[key] = cached
99
+ return cached
100
+
101
+ def stream(
102
+ self,
103
+ account: Account,
104
+ *,
105
+ verify: bool | None = None,
106
+ auto_reconnect: bool = True,
107
+ ) -> Stream:
108
+ """Open a synchronous WebSocket stream for ``account``.
109
+
110
+ Returns a context-managed :class:`~fxsocket.Stream`; use it as
111
+ ``with client.stream(account) as s:`` and iterate it. Raises
112
+ :class:`TerminalNotReadyError` if the account has no WS endpoint yet.
113
+ """
114
+ if not account.ws_url:
115
+ raise TerminalNotReadyError(
116
+ f"Account {account.id} has no WebSocket endpoint yet "
117
+ "(still provisioning, or bridge-only)."
118
+ )
119
+ verify_tls = self.verify_terminal_tls if verify is None else verify
120
+ api_key = self._api_key
121
+ platform = account.platform
122
+ ws_url = account.ws_url
123
+
124
+ def factory() -> AsyncStream:
125
+ return AsyncStream(
126
+ ws_url=ws_url,
127
+ api_key=api_key,
128
+ platform=platform,
129
+ verify=verify_tls,
130
+ auto_reconnect=auto_reconnect,
131
+ )
132
+
133
+ return Stream(factory)
134
+
135
+ def close(self) -> None:
136
+ try:
137
+ for term in self._terminals.values():
138
+ try:
139
+ term.close()
140
+ except Exception:
141
+ pass
142
+ self._terminals.clear()
143
+ finally:
144
+ self._http_client.close()
145
+
146
+ def __enter__(self) -> Client:
147
+ return self
148
+
149
+ def __exit__(self, *exc: object) -> None:
150
+ self.close()
151
+
152
+
153
+ class AsyncClient:
154
+ """Asynchronous FxSocket client.
155
+
156
+ Usage::
157
+
158
+ from fxsocket import AsyncClient
159
+
160
+ async with AsyncClient(api_key="fxs_live_...") as fx:
161
+ accounts = await fx.accounts.list()
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ api_key: str | None = None,
167
+ *,
168
+ base_url: str = DEFAULT_BASE_URL,
169
+ timeout: float = DEFAULT_TIMEOUT,
170
+ verify_terminal_tls: bool = True,
171
+ ) -> None:
172
+ self._api_key = _resolve_api_key(api_key)
173
+ self._timeout = timeout
174
+ self.verify_terminal_tls = verify_terminal_tls
175
+ self._http_client = httpx.AsyncClient(
176
+ base_url=base_url,
177
+ headers=auth_headers(self._api_key),
178
+ timeout=timeout,
179
+ )
180
+ self.accounts = AsyncAccounts(AsyncHTTP(self._http_client))
181
+ self._terminals: dict[tuple[str, str], AsyncTerminalClient] = {}
182
+
183
+ def terminal(
184
+ self,
185
+ account: Account,
186
+ *,
187
+ verify: bool | None = None,
188
+ timeout: float | None = None,
189
+ ) -> AsyncTerminalClient:
190
+ """Return an async REST client bound to ``account``'s terminal.
191
+
192
+ See :meth:`Client.terminal`. Clients are cached per endpoint and closed
193
+ by :meth:`aclose`.
194
+ """
195
+ if not account.rest_url:
196
+ raise TerminalNotReadyError(
197
+ f"Account {account.id} has no terminal endpoint yet "
198
+ "(still provisioning, or bridge-only)."
199
+ )
200
+ key = (account.rest_url, account.platform.value)
201
+ cached = self._terminals.get(key)
202
+ if cached is None:
203
+ cached = AsyncTerminalClient(
204
+ base_url=account.rest_url,
205
+ api_key=self._api_key,
206
+ platform=account.platform,
207
+ verify=self.verify_terminal_tls if verify is None else verify,
208
+ timeout=self._timeout if timeout is None else timeout,
209
+ )
210
+ self._terminals[key] = cached
211
+ return cached
212
+
213
+ def stream(
214
+ self,
215
+ account: Account,
216
+ *,
217
+ verify: bool | None = None,
218
+ auto_reconnect: bool = True,
219
+ ) -> AsyncStream:
220
+ """Open an async WebSocket stream for ``account``.
221
+
222
+ Returns an :class:`~fxsocket.AsyncStream`; use it as
223
+ ``async with client.stream(account) as s:`` and iterate it. Raises
224
+ :class:`TerminalNotReadyError` if the account has no WS endpoint yet.
225
+ """
226
+ if not account.ws_url:
227
+ raise TerminalNotReadyError(
228
+ f"Account {account.id} has no WebSocket endpoint yet "
229
+ "(still provisioning, or bridge-only)."
230
+ )
231
+ return AsyncStream(
232
+ ws_url=account.ws_url,
233
+ api_key=self._api_key,
234
+ platform=account.platform,
235
+ verify=self.verify_terminal_tls if verify is None else verify,
236
+ auto_reconnect=auto_reconnect,
237
+ )
238
+
239
+ async def aclose(self) -> None:
240
+ try:
241
+ for term in self._terminals.values():
242
+ try:
243
+ await term.aclose()
244
+ except Exception:
245
+ pass
246
+ self._terminals.clear()
247
+ finally:
248
+ await self._http_client.aclose()
249
+
250
+ async def __aenter__(self) -> AsyncClient:
251
+ return self
252
+
253
+ async def __aexit__(self, *exc: object) -> None:
254
+ await self.aclose()
fxsocket/config.py ADDED
@@ -0,0 +1,12 @@
1
+ """Library-wide defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ #: Base URL of the public management API (account CRUD + status).
6
+ DEFAULT_BASE_URL = "https://api.fxsocket.com/v1"
7
+
8
+ #: Default request timeout, in seconds, for REST calls.
9
+ DEFAULT_TIMEOUT = 30.0
10
+
11
+ #: Environment variable read when no ``api_key`` is passed explicitly.
12
+ ENV_API_KEY = "FXSOCKET_API_KEY"
fxsocket/enums.py ADDED
@@ -0,0 +1,122 @@
1
+ """Enumerations mirroring the FxSocket / terminal API vocabulary.
2
+
3
+ All are ``str`` enums, so they compare equal to the raw wire values and
4
+ serialize back to them unchanged.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+
11
+
12
+ class Platform(str, Enum):
13
+ """Trading platform of a linked account."""
14
+
15
+ MT4 = "mt4"
16
+ MT5 = "mt5"
17
+
18
+
19
+ class TradingStatus(str, Enum):
20
+ """Unified, public connection status of an account (v1 ``status``)."""
21
+
22
+ CONNECTED = "connected"
23
+ CONNECTING = "connecting"
24
+ DISCONNECTED = "disconnected"
25
+ ERROR = "error"
26
+
27
+
28
+ class OrderOperation(str, Enum):
29
+ """Order operation accepted by the terminal ``/OrderSend``.
30
+
31
+ All eight are accepted by both the MT4 and MT5 terminal APIs (the MT4
32
+ server's ``parse_operation`` maps ``BuyStopLimit``/``SellStopLimit`` too),
33
+ so the SDK does not gate operations by platform.
34
+ """
35
+
36
+ BUY = "Buy"
37
+ SELL = "Sell"
38
+ BUY_LIMIT = "BuyLimit"
39
+ SELL_LIMIT = "SellLimit"
40
+ BUY_STOP = "BuyStop"
41
+ SELL_STOP = "SellStop"
42
+ BUY_STOP_LIMIT = "BuyStopLimit"
43
+ SELL_STOP_LIMIT = "SellStopLimit"
44
+
45
+
46
+ #: Operations that require a ``stop_limit_price``.
47
+ STOP_LIMIT_OPERATIONS = frozenset(
48
+ {OrderOperation.BUY_STOP_LIMIT, OrderOperation.SELL_STOP_LIMIT}
49
+ )
50
+
51
+ #: Operations that are pending orders (require an entry ``price``).
52
+ PENDING_OPERATIONS = frozenset(
53
+ {
54
+ OrderOperation.BUY_LIMIT,
55
+ OrderOperation.SELL_LIMIT,
56
+ OrderOperation.BUY_STOP,
57
+ OrderOperation.SELL_STOP,
58
+ OrderOperation.BUY_STOP_LIMIT,
59
+ OrderOperation.SELL_STOP_LIMIT,
60
+ }
61
+ )
62
+
63
+
64
+ class OrderKind(str, Enum):
65
+ """Whether an opened row is a live position or a resting pending order."""
66
+
67
+ POSITION = "Position"
68
+ PENDING = "Pending"
69
+
70
+
71
+ class DealEntry(str, Enum):
72
+ """Direction of a deal in trade history / the ``trades`` stream."""
73
+
74
+ IN = "In"
75
+ OUT = "Out"
76
+ IN_OUT = "InOut"
77
+
78
+
79
+ class HealthStatus(str, Enum):
80
+ """Roll-up status reported by the terminal ``/status`` endpoint."""
81
+
82
+ READY = "ready"
83
+ STARTING = "starting"
84
+ DEGRADED = "degraded"
85
+ DOWN = "down"
86
+
87
+
88
+ class Timeframe(str, Enum):
89
+ """Candle timeframes.
90
+
91
+ The MT5-only members (``M2``, ``M3``, ``H2``, ``H6``, ``H8``, ``H12``)
92
+ are rejected client-side for MT4 accounts.
93
+ """
94
+
95
+ M1 = "M1"
96
+ M2 = "M2"
97
+ M3 = "M3"
98
+ M5 = "M5"
99
+ M15 = "M15"
100
+ M30 = "M30"
101
+ H1 = "H1"
102
+ H2 = "H2"
103
+ H4 = "H4"
104
+ H6 = "H6"
105
+ H8 = "H8"
106
+ H12 = "H12"
107
+ D1 = "D1"
108
+ W1 = "W1"
109
+ MN1 = "MN1"
110
+
111
+
112
+ #: Timeframes that only MT5 supports.
113
+ MT5_ONLY_TIMEFRAMES = frozenset(
114
+ {
115
+ Timeframe.M2,
116
+ Timeframe.M3,
117
+ Timeframe.H2,
118
+ Timeframe.H6,
119
+ Timeframe.H8,
120
+ Timeframe.H12,
121
+ }
122
+ )