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.
@@ -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)