finatic-server-python 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.
- finatic_server/__init__.py +127 -0
- finatic_server/core/__init__.py +6 -0
- finatic_server/core/api_client.py +868 -0
- finatic_server/core/client.py +577 -0
- finatic_server/types/__init__.py +103 -0
- finatic_server/types/auth.py +142 -0
- finatic_server/types/broker.py +147 -0
- finatic_server/types/common.py +177 -0
- finatic_server/types/orders.py +74 -0
- finatic_server/types/portfolio.py +61 -0
- finatic_server/utils/__init__.py +17 -0
- finatic_server/utils/errors.py +62 -0
- finatic_server_python-0.1.0.dist-info/METADATA +330 -0
- finatic_server_python-0.1.0.dist-info/RECORD +16 -0
- finatic_server_python-0.1.0.dist-info/WHEEL +5 -0
- finatic_server_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""Core API client for handling HTTP requests to the Finatic API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Optional, Dict, Any, TypeVar, Generic, List
|
|
7
|
+
import aiohttp
|
|
8
|
+
from aiohttp import ClientSession, ClientTimeout
|
|
9
|
+
|
|
10
|
+
from ..types import (
|
|
11
|
+
DeviceInfo,
|
|
12
|
+
SessionResponse,
|
|
13
|
+
OtpRequestResponse,
|
|
14
|
+
OtpVerifyResponse,
|
|
15
|
+
SessionAuthenticateResponse,
|
|
16
|
+
PortalUrlResponse,
|
|
17
|
+
SessionValidationResponse,
|
|
18
|
+
UserToken,
|
|
19
|
+
Holding,
|
|
20
|
+
Order,
|
|
21
|
+
Portfolio,
|
|
22
|
+
BrokerInfo,
|
|
23
|
+
BrokerAccount,
|
|
24
|
+
BrokerOrder,
|
|
25
|
+
BrokerPosition,
|
|
26
|
+
BrokerConnection,
|
|
27
|
+
BrokerDataOptions,
|
|
28
|
+
OrdersFilter,
|
|
29
|
+
PositionsFilter,
|
|
30
|
+
AccountsFilter,
|
|
31
|
+
OrderResponse,
|
|
32
|
+
BrokerOrderParams,
|
|
33
|
+
BrokerExtras,
|
|
34
|
+
CryptoOrderOptions,
|
|
35
|
+
OptionsOrderOptions,
|
|
36
|
+
TradingContext,
|
|
37
|
+
ApiPaginationInfo,
|
|
38
|
+
PaginatedResult,
|
|
39
|
+
)
|
|
40
|
+
from ..utils.errors import (
|
|
41
|
+
ApiError,
|
|
42
|
+
AuthenticationError,
|
|
43
|
+
ValidationError,
|
|
44
|
+
RateLimitError,
|
|
45
|
+
NetworkError,
|
|
46
|
+
TimeoutError,
|
|
47
|
+
AuthorizationError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
T = TypeVar('T')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ApiClient:
|
|
54
|
+
"""Handles all HTTP requests to the Finatic API with proper authentication and error handling."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
base_url: str,
|
|
59
|
+
device_info: Optional[DeviceInfo] = None,
|
|
60
|
+
timeout: int = 30
|
|
61
|
+
):
|
|
62
|
+
"""Initialize the API client.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
base_url: Base URL for the API
|
|
66
|
+
device_info: Device information for requests
|
|
67
|
+
timeout: Request timeout in seconds
|
|
68
|
+
"""
|
|
69
|
+
self.base_url = base_url.rstrip('/')
|
|
70
|
+
if not self.base_url.endswith('/api/v1'):
|
|
71
|
+
self.base_url = f"{self.base_url}/api/v1"
|
|
72
|
+
|
|
73
|
+
self.device_info = device_info
|
|
74
|
+
self.timeout = ClientTimeout(total=timeout)
|
|
75
|
+
|
|
76
|
+
# Session state
|
|
77
|
+
self.current_session_id: Optional[str] = None
|
|
78
|
+
self.current_session_state: Optional[str] = None
|
|
79
|
+
self.company_id: Optional[str] = None
|
|
80
|
+
self.csrf_token: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
# Token management
|
|
83
|
+
self.token_info: Optional[Dict[str, Any]] = None
|
|
84
|
+
self.refresh_promise: Optional[asyncio.Future] = None
|
|
85
|
+
self.refresh_buffer_minutes = 5
|
|
86
|
+
|
|
87
|
+
# Trading context
|
|
88
|
+
self.trading_context: TradingContext = TradingContext()
|
|
89
|
+
|
|
90
|
+
# HTTP session
|
|
91
|
+
self._session: Optional[ClientSession] = None
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self):
|
|
94
|
+
"""Async context manager entry."""
|
|
95
|
+
self._session = ClientSession(timeout=self.timeout)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
99
|
+
"""Async context manager exit."""
|
|
100
|
+
if self._session:
|
|
101
|
+
await self._session.close()
|
|
102
|
+
|
|
103
|
+
def _get_session(self) -> ClientSession:
|
|
104
|
+
"""Get the HTTP session, creating one if needed."""
|
|
105
|
+
if self._session is None:
|
|
106
|
+
raise RuntimeError("Client not initialized. Use async context manager or call _ensure_session()")
|
|
107
|
+
return self._session
|
|
108
|
+
|
|
109
|
+
async def _ensure_session(self):
|
|
110
|
+
"""Ensure HTTP session is available."""
|
|
111
|
+
if self._session is None:
|
|
112
|
+
self._session = ClientSession(timeout=self.timeout)
|
|
113
|
+
|
|
114
|
+
def _build_headers(self, access_token: Optional[str] = None, additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
115
|
+
"""Build comprehensive headers for API requests.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
access_token: Access token for authentication
|
|
119
|
+
additional_headers: Additional headers to include
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary of headers
|
|
123
|
+
"""
|
|
124
|
+
headers = {
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Add device info if available
|
|
129
|
+
if self.device_info:
|
|
130
|
+
headers['X-Device-Info'] = json.dumps({
|
|
131
|
+
'ip_address': self.device_info.ip_address,
|
|
132
|
+
'user_agent': self.device_info.user_agent,
|
|
133
|
+
'fingerprint': self.device_info.fingerprint,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# Add session headers if available
|
|
137
|
+
if self.current_session_id:
|
|
138
|
+
headers['X-Session-ID'] = self.current_session_id
|
|
139
|
+
headers['Session-ID'] = self.current_session_id
|
|
140
|
+
|
|
141
|
+
if self.company_id:
|
|
142
|
+
headers['X-Company-ID'] = self.company_id
|
|
143
|
+
|
|
144
|
+
if self.csrf_token:
|
|
145
|
+
headers['X-CSRF-Token'] = self.csrf_token
|
|
146
|
+
|
|
147
|
+
# Add authorization header
|
|
148
|
+
if access_token:
|
|
149
|
+
headers['Authorization'] = f'Bearer {access_token}'
|
|
150
|
+
|
|
151
|
+
# Add additional headers
|
|
152
|
+
if additional_headers:
|
|
153
|
+
headers.update(additional_headers)
|
|
154
|
+
|
|
155
|
+
return headers
|
|
156
|
+
|
|
157
|
+
async def _request(
|
|
158
|
+
self,
|
|
159
|
+
method: str,
|
|
160
|
+
path: str,
|
|
161
|
+
data: Optional[Dict[str, Any]] = None,
|
|
162
|
+
params: Optional[Dict[str, str]] = None,
|
|
163
|
+
access_token: Optional[str] = None,
|
|
164
|
+
additional_headers: Optional[Dict[str, str]] = None
|
|
165
|
+
) -> Dict[str, Any]:
|
|
166
|
+
"""Make an HTTP request to the API.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
method: HTTP method
|
|
170
|
+
path: API path
|
|
171
|
+
data: Request body data
|
|
172
|
+
params: Query parameters
|
|
173
|
+
access_token: Access token for authentication
|
|
174
|
+
additional_headers: Additional headers
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Response data
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ApiError: For API errors
|
|
181
|
+
NetworkError: For network errors
|
|
182
|
+
TimeoutError: For timeout errors
|
|
183
|
+
"""
|
|
184
|
+
await self._ensure_session()
|
|
185
|
+
session = self._get_session()
|
|
186
|
+
|
|
187
|
+
# Build URL
|
|
188
|
+
url = f"{self.base_url}{path}"
|
|
189
|
+
|
|
190
|
+
# Build headers
|
|
191
|
+
headers = self._build_headers(access_token, additional_headers)
|
|
192
|
+
|
|
193
|
+
# Prepare request
|
|
194
|
+
kwargs = {
|
|
195
|
+
'headers': headers,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if data is not None:
|
|
199
|
+
kwargs['json'] = data
|
|
200
|
+
|
|
201
|
+
if params is not None:
|
|
202
|
+
kwargs['params'] = params
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
async with session.request(method, url, **kwargs) as response:
|
|
206
|
+
response_text = await response.text()
|
|
207
|
+
|
|
208
|
+
if not response.ok:
|
|
209
|
+
await self._handle_error_response(response.status, response_text)
|
|
210
|
+
|
|
211
|
+
# Parse response
|
|
212
|
+
try:
|
|
213
|
+
response_data = json.loads(response_text) if response_text else {}
|
|
214
|
+
except json.JSONDecodeError:
|
|
215
|
+
raise ApiError(f"Invalid JSON response: {response_text}", response.status)
|
|
216
|
+
|
|
217
|
+
# Check for API-level errors
|
|
218
|
+
if isinstance(response_data, dict):
|
|
219
|
+
if response_data.get('success') is False:
|
|
220
|
+
raise ApiError(
|
|
221
|
+
response_data.get('message', 'API request failed'),
|
|
222
|
+
response_data.get('status_code', response.status),
|
|
223
|
+
response_data
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if response_data.get('status_code', 200) >= 400:
|
|
227
|
+
raise ApiError(
|
|
228
|
+
response_data.get('message', 'API request failed'),
|
|
229
|
+
response_data.get('status_code', response.status),
|
|
230
|
+
response_data
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return response_data
|
|
234
|
+
|
|
235
|
+
except asyncio.TimeoutError:
|
|
236
|
+
raise TimeoutError("Request timed out")
|
|
237
|
+
except aiohttp.ClientError as e:
|
|
238
|
+
raise NetworkError(f"Network error: {str(e)}")
|
|
239
|
+
|
|
240
|
+
async def _handle_error_response(self, status: int, response_text: str):
|
|
241
|
+
"""Handle error responses from the API."""
|
|
242
|
+
try:
|
|
243
|
+
error_data = json.loads(response_text) if response_text else {}
|
|
244
|
+
except json.JSONDecodeError:
|
|
245
|
+
error_data = {"message": response_text or "Unknown error"}
|
|
246
|
+
|
|
247
|
+
message = error_data.get("message", "Unknown error")
|
|
248
|
+
|
|
249
|
+
# Provide more user-friendly error messages
|
|
250
|
+
if status == 500:
|
|
251
|
+
message = f"Server error: {message}. Please try again later or contact support."
|
|
252
|
+
elif status == 401:
|
|
253
|
+
message = f"Authentication failed: {message}. Please check your API key."
|
|
254
|
+
elif status == 403:
|
|
255
|
+
message = f"Access denied: {message}. Please check your permissions."
|
|
256
|
+
elif status == 404:
|
|
257
|
+
message = f"Resource not found: {message}. Please check the endpoint URL."
|
|
258
|
+
elif status == 429:
|
|
259
|
+
message = f"Rate limit exceeded: {message}. Please wait before retrying."
|
|
260
|
+
elif status >= 500:
|
|
261
|
+
message = f"Server error ({status}): {message}. Please try again later."
|
|
262
|
+
elif status >= 400:
|
|
263
|
+
message = f"Client error ({status}): {message}"
|
|
264
|
+
|
|
265
|
+
if status == 401:
|
|
266
|
+
raise AuthenticationError(message, status, error_data)
|
|
267
|
+
elif status == 403:
|
|
268
|
+
raise AuthorizationError(message, status, error_data)
|
|
269
|
+
elif status == 422:
|
|
270
|
+
raise ValidationError(message, status, error_data)
|
|
271
|
+
elif status == 429:
|
|
272
|
+
raise RateLimitError(message, status, error_data)
|
|
273
|
+
elif status >= 500:
|
|
274
|
+
raise NetworkError(message, status, error_data)
|
|
275
|
+
else:
|
|
276
|
+
raise ApiError(message, status, error_data)
|
|
277
|
+
|
|
278
|
+
# Session management methods
|
|
279
|
+
def set_session_context(self, session_id: str, company_id: str, csrf_token: Optional[str] = None):
|
|
280
|
+
"""Set session context for subsequent requests."""
|
|
281
|
+
self.current_session_id = session_id
|
|
282
|
+
self.company_id = company_id
|
|
283
|
+
self.csrf_token = csrf_token
|
|
284
|
+
|
|
285
|
+
def get_current_session_id(self) -> Optional[str]:
|
|
286
|
+
"""Get the current session ID."""
|
|
287
|
+
return self.current_session_id
|
|
288
|
+
|
|
289
|
+
def get_current_company_id(self) -> Optional[str]:
|
|
290
|
+
"""Get the current company ID."""
|
|
291
|
+
return self.company_id
|
|
292
|
+
|
|
293
|
+
def get_current_csrf_token(self) -> Optional[str]:
|
|
294
|
+
"""Get the current CSRF token."""
|
|
295
|
+
return self.csrf_token
|
|
296
|
+
|
|
297
|
+
# Token management methods
|
|
298
|
+
def set_tokens(self, access_token: str, refresh_token: str, expires_at: str, user_id: Optional[str] = None):
|
|
299
|
+
"""Set authentication tokens."""
|
|
300
|
+
self.token_info = {
|
|
301
|
+
'access_token': access_token,
|
|
302
|
+
'refresh_token': refresh_token,
|
|
303
|
+
'expires_at': expires_at,
|
|
304
|
+
'user_id': user_id,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def get_token_info(self) -> Optional[Dict[str, Any]]:
|
|
308
|
+
"""Get current token info."""
|
|
309
|
+
return self.token_info
|
|
310
|
+
|
|
311
|
+
async def get_valid_access_token(self) -> str:
|
|
312
|
+
"""Get a valid access token, refreshing if necessary."""
|
|
313
|
+
if not self.token_info:
|
|
314
|
+
raise AuthenticationError("No tokens available. Please authenticate first.")
|
|
315
|
+
|
|
316
|
+
# Check if token is expired or about to expire
|
|
317
|
+
if self._is_token_expired():
|
|
318
|
+
await self._refresh_tokens()
|
|
319
|
+
|
|
320
|
+
return self.token_info['access_token']
|
|
321
|
+
|
|
322
|
+
def _is_token_expired(self) -> bool:
|
|
323
|
+
"""Check if the current token is expired or about to expire."""
|
|
324
|
+
if not self.token_info:
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
expires_at = datetime.fromisoformat(self.token_info['expires_at'].replace('Z', '+00:00'))
|
|
328
|
+
current_time = datetime.now(expires_at.tzinfo)
|
|
329
|
+
buffer_time = timedelta(minutes=self.refresh_buffer_minutes)
|
|
330
|
+
|
|
331
|
+
return current_time >= expires_at - buffer_time
|
|
332
|
+
|
|
333
|
+
async def _refresh_tokens(self):
|
|
334
|
+
"""Refresh authentication tokens."""
|
|
335
|
+
if not self.token_info:
|
|
336
|
+
raise AuthenticationError("No refresh token available.")
|
|
337
|
+
|
|
338
|
+
# If a refresh is already in progress, wait for it
|
|
339
|
+
if self.refresh_promise:
|
|
340
|
+
await self.refresh_promise
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
# Start a new refresh
|
|
344
|
+
self.refresh_promise = self._perform_token_refresh()
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
await self.refresh_promise
|
|
348
|
+
finally:
|
|
349
|
+
self.refresh_promise = None
|
|
350
|
+
|
|
351
|
+
async def _perform_token_refresh(self):
|
|
352
|
+
"""Perform the actual token refresh request."""
|
|
353
|
+
if not self.token_info:
|
|
354
|
+
raise AuthenticationError("No refresh token available.")
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
response = await self._request(
|
|
358
|
+
method='POST',
|
|
359
|
+
path='/company/auth/refresh',
|
|
360
|
+
data={
|
|
361
|
+
'refresh_token': self.token_info['refresh_token']
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Update stored tokens
|
|
366
|
+
self.token_info = {
|
|
367
|
+
'access_token': response['response_data']['access_token'],
|
|
368
|
+
'refresh_token': response['response_data']['refresh_token'],
|
|
369
|
+
'expires_at': response['response_data']['expires_at'],
|
|
370
|
+
'user_id': self.token_info.get('user_id')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return self.token_info
|
|
374
|
+
|
|
375
|
+
except Exception as e:
|
|
376
|
+
# Clear tokens on refresh failure
|
|
377
|
+
self.token_info = None
|
|
378
|
+
raise AuthenticationError(f"Token refresh failed. Please re-authenticate: {str(e)}")
|
|
379
|
+
|
|
380
|
+
def clear_tokens(self):
|
|
381
|
+
"""Clear stored tokens."""
|
|
382
|
+
self.token_info = None
|
|
383
|
+
self.refresh_promise = None
|
|
384
|
+
|
|
385
|
+
def get_current_session_state(self) -> Optional[str]:
|
|
386
|
+
"""Get current session state."""
|
|
387
|
+
return self.current_session_state
|
|
388
|
+
|
|
389
|
+
# Simple methods that automatically use stored tokens
|
|
390
|
+
async def get_holdings_auto(self) -> List[Holding]:
|
|
391
|
+
"""Get holdings using stored access token."""
|
|
392
|
+
access_token = await self.get_valid_access_token()
|
|
393
|
+
response = await self._request(
|
|
394
|
+
method='GET',
|
|
395
|
+
path='/portfolio/holdings',
|
|
396
|
+
access_token=access_token
|
|
397
|
+
)
|
|
398
|
+
return [Holding(**holding) for holding in response.get('data', [])]
|
|
399
|
+
|
|
400
|
+
async def get_orders_auto(self) -> List[Order]:
|
|
401
|
+
"""Get orders using stored access token."""
|
|
402
|
+
access_token = await self.get_valid_access_token()
|
|
403
|
+
response = await self._request(
|
|
404
|
+
method='GET',
|
|
405
|
+
path='/data/orders',
|
|
406
|
+
access_token=access_token
|
|
407
|
+
)
|
|
408
|
+
return [Order(**order) for order in response.get('data', [])]
|
|
409
|
+
|
|
410
|
+
async def get_portfolio_auto(self) -> Portfolio:
|
|
411
|
+
"""Get portfolio using stored access token."""
|
|
412
|
+
access_token = await self.get_valid_access_token()
|
|
413
|
+
response = await self._request(
|
|
414
|
+
method='GET',
|
|
415
|
+
path='/portfolio/',
|
|
416
|
+
access_token=access_token
|
|
417
|
+
)
|
|
418
|
+
return Portfolio(**response.get('data', {}))
|
|
419
|
+
|
|
420
|
+
async def get_broker_list_auto(self) -> List[BrokerInfo]:
|
|
421
|
+
"""Get broker list using stored access token."""
|
|
422
|
+
access_token = await self.get_valid_access_token()
|
|
423
|
+
response = await self._request(
|
|
424
|
+
method='GET',
|
|
425
|
+
path='/brokers/',
|
|
426
|
+
access_token=access_token
|
|
427
|
+
)
|
|
428
|
+
return [BrokerInfo(**broker) for broker in response.get('response_data', [])]
|
|
429
|
+
|
|
430
|
+
async def get_broker_accounts(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[AccountsFilter] = None) -> PaginatedResult:
|
|
431
|
+
"""Get broker accounts with pagination support."""
|
|
432
|
+
access_token = await self.get_valid_access_token()
|
|
433
|
+
offset = (page - 1) * per_page
|
|
434
|
+
|
|
435
|
+
# Build query parameters
|
|
436
|
+
params = {
|
|
437
|
+
'limit': str(per_page),
|
|
438
|
+
'offset': str(offset),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if options:
|
|
442
|
+
if options.broker_name:
|
|
443
|
+
params['broker_name'] = options.broker_name
|
|
444
|
+
if options.account_id:
|
|
445
|
+
params['account_id'] = options.account_id
|
|
446
|
+
|
|
447
|
+
if filters:
|
|
448
|
+
if filters.broker_id:
|
|
449
|
+
params['broker_id'] = filters.broker_id
|
|
450
|
+
if filters.connection_id:
|
|
451
|
+
params['connection_id'] = filters.connection_id
|
|
452
|
+
if filters.account_type:
|
|
453
|
+
params['account_type'] = filters.account_type
|
|
454
|
+
if filters.status:
|
|
455
|
+
params['status'] = filters.status
|
|
456
|
+
if filters.currency:
|
|
457
|
+
params['currency'] = filters.currency
|
|
458
|
+
|
|
459
|
+
response = await self._request(
|
|
460
|
+
method='GET',
|
|
461
|
+
path='/brokers/data/accounts',
|
|
462
|
+
access_token=access_token,
|
|
463
|
+
params=params
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Create navigation callback for pagination
|
|
467
|
+
async def navigation_callback(new_offset: int, new_limit: int) -> PaginatedResult:
|
|
468
|
+
new_params = {
|
|
469
|
+
'limit': str(new_limit),
|
|
470
|
+
'offset': str(new_offset),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if options:
|
|
474
|
+
if options.broker_name:
|
|
475
|
+
new_params['broker_name'] = options.broker_name
|
|
476
|
+
if options.account_id:
|
|
477
|
+
new_params['account_id'] = options.account_id
|
|
478
|
+
|
|
479
|
+
if filters:
|
|
480
|
+
if filters.broker_id:
|
|
481
|
+
new_params['broker_id'] = filters.broker_id
|
|
482
|
+
if filters.connection_id:
|
|
483
|
+
new_params['connection_id'] = filters.connection_id
|
|
484
|
+
if filters.account_type:
|
|
485
|
+
new_params['account_type'] = filters.account_type
|
|
486
|
+
if filters.status:
|
|
487
|
+
new_params['status'] = filters.status
|
|
488
|
+
if filters.currency:
|
|
489
|
+
new_params['currency'] = filters.currency
|
|
490
|
+
|
|
491
|
+
new_response = await self._request(
|
|
492
|
+
method='GET',
|
|
493
|
+
path='/brokers/data/accounts',
|
|
494
|
+
access_token=access_token,
|
|
495
|
+
params=new_params
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
pagination_info = ApiPaginationInfo(
|
|
499
|
+
has_more=new_response.get('pagination', {}).get('has_more', False),
|
|
500
|
+
next_offset=new_response.get('pagination', {}).get('next_offset', new_offset),
|
|
501
|
+
current_offset=new_response.get('pagination', {}).get('current_offset', new_offset),
|
|
502
|
+
limit=new_response.get('pagination', {}).get('limit', new_limit),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return PaginatedResult(
|
|
506
|
+
[BrokerAccount(**account) for account in new_response.get('response_data', [])],
|
|
507
|
+
pagination_info,
|
|
508
|
+
navigation_callback
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
pagination_info = ApiPaginationInfo(
|
|
512
|
+
has_more=response.get('pagination', {}).get('has_more', False),
|
|
513
|
+
next_offset=response.get('pagination', {}).get('next_offset', offset),
|
|
514
|
+
current_offset=response.get('pagination', {}).get('current_offset', offset),
|
|
515
|
+
limit=response.get('pagination', {}).get('limit', per_page),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return PaginatedResult(
|
|
519
|
+
[BrokerAccount(**account) for account in response.get('response_data', [])],
|
|
520
|
+
pagination_info,
|
|
521
|
+
navigation_callback
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
async def get_broker_orders(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[OrdersFilter] = None) -> PaginatedResult:
|
|
525
|
+
"""Get broker orders with pagination support."""
|
|
526
|
+
access_token = await self.get_valid_access_token()
|
|
527
|
+
offset = (page - 1) * per_page
|
|
528
|
+
|
|
529
|
+
# Build query parameters
|
|
530
|
+
params = {
|
|
531
|
+
'limit': str(per_page),
|
|
532
|
+
'offset': str(offset),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if options:
|
|
536
|
+
if options.broker_name:
|
|
537
|
+
params['broker_name'] = options.broker_name
|
|
538
|
+
if options.account_id:
|
|
539
|
+
params['account_id'] = options.account_id
|
|
540
|
+
if options.symbol:
|
|
541
|
+
params['symbol'] = options.symbol
|
|
542
|
+
|
|
543
|
+
if filters:
|
|
544
|
+
if filters.broker_id:
|
|
545
|
+
params['broker_id'] = filters.broker_id
|
|
546
|
+
if filters.connection_id:
|
|
547
|
+
params['connection_id'] = filters.connection_id
|
|
548
|
+
if filters.account_id:
|
|
549
|
+
params['account_id'] = filters.account_id
|
|
550
|
+
if filters.symbol:
|
|
551
|
+
params['symbol'] = filters.symbol
|
|
552
|
+
if filters.status:
|
|
553
|
+
params['status'] = filters.status
|
|
554
|
+
if filters.side:
|
|
555
|
+
params['side'] = filters.side
|
|
556
|
+
if filters.asset_type:
|
|
557
|
+
params['asset_type'] = filters.asset_type
|
|
558
|
+
if filters.created_after:
|
|
559
|
+
params['created_after'] = filters.created_after
|
|
560
|
+
if filters.created_before:
|
|
561
|
+
params['created_before'] = filters.created_before
|
|
562
|
+
|
|
563
|
+
response = await self._request(
|
|
564
|
+
method='GET',
|
|
565
|
+
path='/brokers/data/orders',
|
|
566
|
+
access_token=access_token,
|
|
567
|
+
params=params
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Create navigation callback for pagination
|
|
571
|
+
async def navigation_callback(new_offset: int, new_limit: int) -> PaginatedResult:
|
|
572
|
+
new_params = {
|
|
573
|
+
'limit': str(new_limit),
|
|
574
|
+
'offset': str(new_offset),
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if options:
|
|
578
|
+
if options.broker_name:
|
|
579
|
+
new_params['broker_name'] = options.broker_name
|
|
580
|
+
if options.account_id:
|
|
581
|
+
new_params['account_id'] = options.account_id
|
|
582
|
+
if options.symbol:
|
|
583
|
+
new_params['symbol'] = options.symbol
|
|
584
|
+
|
|
585
|
+
if filters:
|
|
586
|
+
if filters.broker_id:
|
|
587
|
+
new_params['broker_id'] = filters.broker_id
|
|
588
|
+
if filters.connection_id:
|
|
589
|
+
new_params['connection_id'] = filters.connection_id
|
|
590
|
+
if filters.account_id:
|
|
591
|
+
new_params['account_id'] = filters.account_id
|
|
592
|
+
if filters.symbol:
|
|
593
|
+
new_params['symbol'] = filters.symbol
|
|
594
|
+
if filters.status:
|
|
595
|
+
new_params['status'] = filters.status
|
|
596
|
+
if filters.side:
|
|
597
|
+
new_params['side'] = filters.side
|
|
598
|
+
if filters.asset_type:
|
|
599
|
+
new_params['asset_type'] = filters.asset_type
|
|
600
|
+
if filters.created_after:
|
|
601
|
+
new_params['created_after'] = filters.created_after
|
|
602
|
+
if filters.created_before:
|
|
603
|
+
new_params['created_before'] = filters.created_before
|
|
604
|
+
|
|
605
|
+
new_response = await self._request(
|
|
606
|
+
method='GET',
|
|
607
|
+
path='/brokers/data/orders',
|
|
608
|
+
access_token=access_token,
|
|
609
|
+
params=new_params
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
pagination_info = ApiPaginationInfo(
|
|
613
|
+
has_more=new_response.get('pagination', {}).get('has_more', False),
|
|
614
|
+
next_offset=new_response.get('pagination', {}).get('next_offset', new_offset),
|
|
615
|
+
current_offset=new_response.get('pagination', {}).get('current_offset', new_offset),
|
|
616
|
+
limit=new_response.get('pagination', {}).get('limit', new_limit),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
return PaginatedResult(
|
|
620
|
+
[BrokerOrder(**order) for order in new_response.get('response_data', [])],
|
|
621
|
+
pagination_info,
|
|
622
|
+
navigation_callback
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
pagination_info = ApiPaginationInfo(
|
|
626
|
+
has_more=response.get('pagination', {}).get('has_more', False),
|
|
627
|
+
next_offset=response.get('pagination', {}).get('next_offset', offset),
|
|
628
|
+
current_offset=response.get('pagination', {}).get('current_offset', offset),
|
|
629
|
+
limit=response.get('pagination', {}).get('limit', per_page),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return PaginatedResult(
|
|
633
|
+
[BrokerOrder(**order) for order in response.get('response_data', [])],
|
|
634
|
+
pagination_info,
|
|
635
|
+
navigation_callback
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
async def get_broker_positions(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[PositionsFilter] = None) -> PaginatedResult:
|
|
639
|
+
"""Get broker positions with pagination support."""
|
|
640
|
+
access_token = await self.get_valid_access_token()
|
|
641
|
+
offset = (page - 1) * per_page
|
|
642
|
+
|
|
643
|
+
# Build query parameters
|
|
644
|
+
params = {
|
|
645
|
+
'limit': str(per_page),
|
|
646
|
+
'offset': str(offset),
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if options:
|
|
650
|
+
if options.broker_name:
|
|
651
|
+
params['broker_name'] = options.broker_name
|
|
652
|
+
if options.account_id:
|
|
653
|
+
params['account_id'] = options.account_id
|
|
654
|
+
if options.symbol:
|
|
655
|
+
params['symbol'] = options.symbol
|
|
656
|
+
|
|
657
|
+
if filters:
|
|
658
|
+
if filters.broker_id:
|
|
659
|
+
params['broker_id'] = filters.broker_id
|
|
660
|
+
if filters.connection_id:
|
|
661
|
+
params['connection_id'] = filters.connection_id
|
|
662
|
+
if filters.account_id:
|
|
663
|
+
params['account_id'] = filters.account_id
|
|
664
|
+
if filters.symbol:
|
|
665
|
+
params['symbol'] = filters.symbol
|
|
666
|
+
if filters.side:
|
|
667
|
+
params['side'] = filters.side
|
|
668
|
+
if filters.asset_type:
|
|
669
|
+
params['asset_type'] = filters.asset_type
|
|
670
|
+
if filters.position_status:
|
|
671
|
+
params['position_status'] = filters.position_status
|
|
672
|
+
if filters.updated_after:
|
|
673
|
+
params['updated_after'] = filters.updated_after
|
|
674
|
+
if filters.updated_before:
|
|
675
|
+
params['updated_before'] = filters.updated_before
|
|
676
|
+
|
|
677
|
+
response = await self._request(
|
|
678
|
+
method='GET',
|
|
679
|
+
path='/brokers/data/positions',
|
|
680
|
+
access_token=access_token,
|
|
681
|
+
params=params
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Create navigation callback for pagination
|
|
685
|
+
async def navigation_callback(new_offset: int, new_limit: int) -> PaginatedResult:
|
|
686
|
+
new_params = {
|
|
687
|
+
'limit': str(new_limit),
|
|
688
|
+
'offset': str(new_offset),
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if options:
|
|
692
|
+
if options.broker_name:
|
|
693
|
+
new_params['broker_name'] = options.broker_name
|
|
694
|
+
if options.account_id:
|
|
695
|
+
new_params['account_id'] = options.account_id
|
|
696
|
+
if options.symbol:
|
|
697
|
+
new_params['symbol'] = options.symbol
|
|
698
|
+
|
|
699
|
+
if filters:
|
|
700
|
+
if filters.broker_id:
|
|
701
|
+
new_params['broker_id'] = filters.broker_id
|
|
702
|
+
if filters.connection_id:
|
|
703
|
+
new_params['connection_id'] = filters.connection_id
|
|
704
|
+
if filters.account_id:
|
|
705
|
+
new_params['account_id'] = filters.account_id
|
|
706
|
+
if filters.symbol:
|
|
707
|
+
new_params['symbol'] = filters.symbol
|
|
708
|
+
if filters.side:
|
|
709
|
+
new_params['side'] = filters.side
|
|
710
|
+
if filters.asset_type:
|
|
711
|
+
new_params['asset_type'] = filters.asset_type
|
|
712
|
+
if filters.position_status:
|
|
713
|
+
new_params['position_status'] = filters.position_status
|
|
714
|
+
if filters.updated_after:
|
|
715
|
+
new_params['updated_after'] = filters.updated_after
|
|
716
|
+
if filters.updated_before:
|
|
717
|
+
new_params['updated_before'] = filters.updated_before
|
|
718
|
+
|
|
719
|
+
new_response = await self._request(
|
|
720
|
+
method='GET',
|
|
721
|
+
path='/brokers/data/positions',
|
|
722
|
+
access_token=access_token,
|
|
723
|
+
params=new_params
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
pagination_info = ApiPaginationInfo(
|
|
727
|
+
has_more=new_response.get('pagination', {}).get('has_more', False),
|
|
728
|
+
next_offset=new_response.get('pagination', {}).get('next_offset', new_offset),
|
|
729
|
+
current_offset=new_response.get('pagination', {}).get('current_offset', new_offset),
|
|
730
|
+
limit=new_response.get('pagination', {}).get('limit', new_limit),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
return PaginatedResult(
|
|
734
|
+
[BrokerPosition(**position) for position in new_response.get('response_data', [])],
|
|
735
|
+
pagination_info,
|
|
736
|
+
navigation_callback
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
pagination_info = ApiPaginationInfo(
|
|
740
|
+
has_more=response.get('pagination', {}).get('has_more', False),
|
|
741
|
+
next_offset=response.get('pagination', {}).get('next_offset', offset),
|
|
742
|
+
current_offset=response.get('pagination', {}).get('current_offset', offset),
|
|
743
|
+
limit=response.get('pagination', {}).get('limit', per_page),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return PaginatedResult(
|
|
747
|
+
[BrokerPosition(**position) for position in response.get('response_data', [])],
|
|
748
|
+
pagination_info,
|
|
749
|
+
navigation_callback
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Helper methods to get all data across pages
|
|
753
|
+
async def get_all_broker_accounts(self, options: Optional[BrokerDataOptions] = None, filters: Optional[AccountsFilter] = None) -> List[BrokerAccount]:
|
|
754
|
+
"""Get all broker accounts across all pages."""
|
|
755
|
+
all_accounts = []
|
|
756
|
+
page = 1
|
|
757
|
+
per_page = 100
|
|
758
|
+
|
|
759
|
+
while True:
|
|
760
|
+
result = await self.get_broker_accounts(page, per_page, options, filters)
|
|
761
|
+
if not result.data:
|
|
762
|
+
break
|
|
763
|
+
all_accounts.extend(result.data)
|
|
764
|
+
if not result.has_next:
|
|
765
|
+
break
|
|
766
|
+
page += 1
|
|
767
|
+
|
|
768
|
+
return all_accounts
|
|
769
|
+
|
|
770
|
+
async def get_all_broker_orders(self, options: Optional[BrokerDataOptions] = None, filters: Optional[OrdersFilter] = None) -> List[BrokerOrder]:
|
|
771
|
+
"""Get all broker orders across all pages."""
|
|
772
|
+
all_orders = []
|
|
773
|
+
page = 1
|
|
774
|
+
per_page = 100
|
|
775
|
+
|
|
776
|
+
while True:
|
|
777
|
+
result = await self.get_broker_orders(page, per_page, options, filters)
|
|
778
|
+
if not result.data:
|
|
779
|
+
break
|
|
780
|
+
all_orders.extend(result.data)
|
|
781
|
+
if not result.has_next:
|
|
782
|
+
break
|
|
783
|
+
page += 1
|
|
784
|
+
|
|
785
|
+
return all_orders
|
|
786
|
+
|
|
787
|
+
async def get_all_broker_positions(self, options: Optional[BrokerDataOptions] = None, filters: Optional[PositionsFilter] = None) -> List[BrokerPosition]:
|
|
788
|
+
"""Get all broker positions across all pages."""
|
|
789
|
+
all_positions = []
|
|
790
|
+
page = 1
|
|
791
|
+
per_page = 100
|
|
792
|
+
|
|
793
|
+
while True:
|
|
794
|
+
result = await self.get_broker_positions(page, per_page, options, filters)
|
|
795
|
+
if not result.data:
|
|
796
|
+
break
|
|
797
|
+
all_positions.extend(result.data)
|
|
798
|
+
if not result.has_next:
|
|
799
|
+
break
|
|
800
|
+
page += 1
|
|
801
|
+
|
|
802
|
+
return all_positions
|
|
803
|
+
|
|
804
|
+
async def get_broker_connections_auto(self) -> List[BrokerConnection]:
|
|
805
|
+
"""Get broker connections using stored access token."""
|
|
806
|
+
access_token = await self.get_valid_access_token()
|
|
807
|
+
response = await self._request(
|
|
808
|
+
method='GET',
|
|
809
|
+
path='/brokers/connections',
|
|
810
|
+
access_token=access_token
|
|
811
|
+
)
|
|
812
|
+
return [BrokerConnection(**connection) for connection in response.get('response_data', [])]
|
|
813
|
+
|
|
814
|
+
# Trading context methods
|
|
815
|
+
def set_broker(self, broker: str):
|
|
816
|
+
"""Set the current broker."""
|
|
817
|
+
self.trading_context.broker = broker
|
|
818
|
+
|
|
819
|
+
def set_account(self, account_number: str, account_id: Optional[str] = None):
|
|
820
|
+
"""Set the current account."""
|
|
821
|
+
self.trading_context.account_number = account_number
|
|
822
|
+
self.trading_context.account_id = account_id
|
|
823
|
+
|
|
824
|
+
def get_trading_context(self) -> TradingContext:
|
|
825
|
+
"""Get the current trading context."""
|
|
826
|
+
return self.trading_context
|
|
827
|
+
|
|
828
|
+
def clear_trading_context(self):
|
|
829
|
+
"""Clear the trading context."""
|
|
830
|
+
self.trading_context = TradingContext()
|
|
831
|
+
|
|
832
|
+
def is_mock_client(self) -> bool:
|
|
833
|
+
"""Check if this is a mock client."""
|
|
834
|
+
return False
|
|
835
|
+
|
|
836
|
+
async def get_portal_url(self, session_id: str) -> PortalUrlResponse:
|
|
837
|
+
"""Get portal URL for session."""
|
|
838
|
+
response = await self._request(
|
|
839
|
+
method='GET',
|
|
840
|
+
path=f'/auth/session/portal',
|
|
841
|
+
additional_headers={
|
|
842
|
+
'X-Session-ID': session_id,
|
|
843
|
+
}
|
|
844
|
+
)
|
|
845
|
+
return PortalUrlResponse(**response)
|
|
846
|
+
|
|
847
|
+
async def get_session_user(self, session_id: str, company_id: str):
|
|
848
|
+
"""Get user and tokens for completed session.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
session_id: Session ID to use as Bearer token
|
|
852
|
+
company_id: Company ID for session validation
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
SessionUserResponse with user info and tokens
|
|
856
|
+
"""
|
|
857
|
+
response = await self._request(
|
|
858
|
+
method='GET',
|
|
859
|
+
path=f'/auth/session/{session_id}/user',
|
|
860
|
+
additional_headers={
|
|
861
|
+
'Authorization': f'Bearer {session_id}',
|
|
862
|
+
'Company-ID': company_id,
|
|
863
|
+
}
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# Import here to avoid circular imports
|
|
867
|
+
from ..types.auth import SessionUserResponse
|
|
868
|
+
return SessionUserResponse(**response)
|