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,577 @@
1
+ """Main client class for the Finatic Server SDK."""
2
+
3
+ import asyncio
4
+ from typing import Optional, Dict, Any, List
5
+ from datetime import datetime, timedelta
6
+
7
+ from .api_client import ApiClient
8
+ from ..types import (
9
+ DeviceInfo,
10
+ SessionInitResponse,
11
+ SessionResponse,
12
+ OtpRequestResponse,
13
+ OtpVerifyResponse,
14
+ SessionAuthenticateResponse,
15
+ PortalUrlResponse,
16
+ UserToken,
17
+ Holding,
18
+ Order,
19
+ Portfolio,
20
+ BrokerInfo,
21
+ BrokerAccount,
22
+ BrokerOrder,
23
+ BrokerPosition,
24
+ BrokerConnection,
25
+ BrokerDataOptions,
26
+ OrdersFilter,
27
+ PositionsFilter,
28
+ AccountsFilter,
29
+ OrderResponse,
30
+ BrokerOrderParams,
31
+ BrokerExtras,
32
+ CryptoOrderOptions,
33
+ OptionsOrderOptions,
34
+ TradingContext,
35
+ ApiPaginationInfo,
36
+ PaginatedResult,
37
+ )
38
+ from ..utils.errors import (
39
+ AuthenticationError,
40
+ ValidationError,
41
+ ApiError,
42
+ )
43
+
44
+
45
+ class FinaticServerClient:
46
+ """Main client for interacting with the Finatic Server API.
47
+
48
+ This client provides a high-level interface for authentication, portfolio management,
49
+ and trading operations. It handles API key authentication and session management
50
+ automatically.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str,
56
+ base_url: str = "https://api.finatic.dev",
57
+ device_info: Optional[DeviceInfo] = None,
58
+ timeout: int = 30
59
+ ):
60
+ """Initialize the Finatic Server client.
61
+
62
+ Args:
63
+ api_key: API key for authentication
64
+ base_url: Base URL for the API
65
+ device_info: Device information for requests
66
+ timeout: Request timeout in seconds
67
+ """
68
+ self.api_key = api_key
69
+ self.base_url = base_url
70
+ self.device_info = device_info
71
+ self.timeout = timeout
72
+
73
+ # Initialize API client
74
+ self._api_client = ApiClient(base_url, device_info, timeout)
75
+
76
+ # Session state
77
+ self._session_id: Optional[str] = None
78
+ self._company_id: Optional[str] = None
79
+ self._user_token: Optional[UserToken] = None
80
+ self._one_time_token: Optional[str] = None
81
+
82
+ # Trading context
83
+ self._trading_context: TradingContext = TradingContext()
84
+
85
+ async def __aenter__(self):
86
+ """Async context manager entry."""
87
+ await self._api_client.__aenter__()
88
+ return self
89
+
90
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
91
+ """Async context manager exit."""
92
+ try:
93
+ await self._api_client.__aexit__(exc_type, exc_val, exc_tb)
94
+ except Exception as e:
95
+ # Log cleanup errors but don't raise them
96
+ print(f"Warning: Error during client cleanup: {e}")
97
+
98
+ def __del__(self):
99
+ """Destructor to ensure cleanup if context manager is not used."""
100
+ try:
101
+ if hasattr(self, '_api_client') and self._api_client:
102
+ # Try to close the session if it's still open
103
+ if hasattr(self._api_client, '_session') and self._api_client._session:
104
+ if not self._api_client._session.closed:
105
+ asyncio.create_task(self._api_client._session.close())
106
+ except Exception:
107
+ # Ignore cleanup errors in destructor
108
+ pass
109
+
110
+ async def _initialize_session(self) -> str:
111
+ """Initialize a session by getting a one-time token.
112
+
113
+ Returns:
114
+ One-time token for session initialization
115
+
116
+ Raises:
117
+ AuthenticationError: If API key is invalid
118
+ ApiError: For other API errors
119
+ """
120
+ if self._one_time_token:
121
+ return self._one_time_token
122
+
123
+ # Call the session init endpoint with API key
124
+ response = await self._api_client._request(
125
+ method='POST',
126
+ path='/auth/session/init',
127
+ additional_headers={'X-API-Key': self.api_key}
128
+ )
129
+
130
+ session_init = SessionInitResponse(**response)
131
+ self._one_time_token = session_init.data['one_time_token']
132
+
133
+ return self._one_time_token
134
+
135
+ async def start_session(self, user_id: Optional[str] = None) -> SessionResponse:
136
+ """Start a session with the one-time token.
137
+
138
+ Args:
139
+ user_id: Optional user ID for direct authentication
140
+
141
+ Returns:
142
+ Session response
143
+
144
+ Raises:
145
+ AuthenticationError: If token is invalid
146
+ ApiError: For other API errors
147
+ """
148
+ # Get one-time token if not already available
149
+ token = await self._initialize_session()
150
+
151
+ # Start session
152
+ response = await self._api_client._request(
153
+ method='POST',
154
+ path='/auth/session/start',
155
+ data={'user_id': user_id} if user_id else {},
156
+ additional_headers={'One-Time-Token': token}
157
+ )
158
+
159
+ session_response = SessionResponse(**response)
160
+
161
+ # Handle both nested data structure and flat structure
162
+ if session_response.data:
163
+ # Nested structure (like frontend SDK expects)
164
+ self._session_id = session_response.data.session_id
165
+ self._company_id = session_response.data.company_id
166
+ else:
167
+ # Flat structure (what your API currently returns)
168
+ self._session_id = session_response.session_id
169
+ self._company_id = session_response.company_id
170
+
171
+ # Set session context in API client (only if we have valid IDs)
172
+ if self._session_id and self._company_id:
173
+ self._api_client.set_session_context(
174
+ self._session_id,
175
+ self._company_id
176
+ )
177
+
178
+ return session_response
179
+
180
+ async def request_otp(self, email: str) -> OtpRequestResponse:
181
+ """Request OTP for session authentication.
182
+
183
+ Args:
184
+ email: Email address to send OTP to
185
+
186
+ Returns:
187
+ OTP request response
188
+
189
+ Raises:
190
+ AuthenticationError: If session is not active
191
+ ValidationError: If email is invalid
192
+ ApiError: For other API errors
193
+ """
194
+ if not self._session_id:
195
+ raise AuthenticationError("Session not initialized. Call start_session() first.")
196
+
197
+ response = await self._api_client._request(
198
+ method='POST',
199
+ path='/auth/otp/request',
200
+ data={'email': email}
201
+ )
202
+
203
+ return OtpRequestResponse(**response)
204
+
205
+ async def verify_otp(self, otp: str) -> OtpVerifyResponse:
206
+ """Verify OTP for session authentication.
207
+
208
+ Args:
209
+ otp: One-time password to verify
210
+
211
+ Returns:
212
+ OTP verification response with tokens
213
+
214
+ Raises:
215
+ AuthenticationError: If OTP is invalid or session is not active
216
+ ApiError: For other API errors
217
+ """
218
+ if not self._session_id:
219
+ raise AuthenticationError("Session not initialized. Call start_session() first.")
220
+
221
+ response = await self._api_client._request(
222
+ method='POST',
223
+ path='/auth/otp/verify',
224
+ data={'otp': otp}
225
+ )
226
+
227
+ otp_response = OtpVerifyResponse(**response)
228
+
229
+ # Store tokens
230
+ if otp_response.success and otp_response.data:
231
+ self._user_token = UserToken(
232
+ access_token=otp_response.data['access_token'],
233
+ refresh_token=otp_response.data['refresh_token'],
234
+ expires_in=otp_response.data['expires_in'],
235
+ user_id=otp_response.data['user_id'],
236
+ token_type=otp_response.data.get('token_type', 'Bearer'),
237
+ scope=otp_response.data.get('scope', '')
238
+ )
239
+
240
+ # Set tokens in API client
241
+ expires_at = (datetime.now() + timedelta(seconds=otp_response.data['expires_in'])).isoformat()
242
+ self._api_client.set_tokens(
243
+ otp_response.data['access_token'],
244
+ otp_response.data['refresh_token'],
245
+ expires_at,
246
+ otp_response.data['user_id']
247
+ )
248
+
249
+ return otp_response
250
+
251
+ async def authenticate_directly(self, user_id: str) -> SessionAuthenticateResponse:
252
+ """Authenticate session directly with user ID.
253
+
254
+ Args:
255
+ user_id: User ID for direct authentication
256
+
257
+ Returns:
258
+ Authentication response with tokens
259
+
260
+ Raises:
261
+ AuthenticationError: If authentication fails
262
+ ApiError: For other API errors
263
+ """
264
+ if not self._session_id:
265
+ raise AuthenticationError("Session not initialized. Call start_session() first.")
266
+
267
+ response = await self._api_client._request(
268
+ method='POST',
269
+ path='/auth/session/authenticate',
270
+ data={'session_id': self._session_id, 'user_id': user_id}
271
+ )
272
+
273
+ auth_response = SessionAuthenticateResponse(**response)
274
+
275
+ # Store tokens
276
+ if auth_response.success and auth_response.data:
277
+ self._user_token = UserToken(
278
+ access_token=auth_response.data['access_token'],
279
+ refresh_token=auth_response.data['refresh_token'],
280
+ expires_in=3600, # Default 1 hour
281
+ user_id=user_id,
282
+ token_type='Bearer',
283
+ scope='api:access'
284
+ )
285
+
286
+ # Set tokens in API client
287
+ expires_at = (datetime.now() + timedelta(hours=1)).isoformat()
288
+ self._api_client.set_tokens(
289
+ auth_response.data['access_token'],
290
+ auth_response.data['refresh_token'],
291
+ expires_at,
292
+ user_id
293
+ )
294
+
295
+ return auth_response
296
+
297
+ async def get_portal_url(self) -> str:
298
+ """Get the portal URL for user authentication.
299
+
300
+ Returns:
301
+ Portal URL string
302
+
303
+ Raises:
304
+ AuthenticationError: If session is not initialized
305
+ """
306
+ if not self._session_id:
307
+ raise AuthenticationError("Session not initialized. Call start_session() first.")
308
+
309
+ try:
310
+ response = await self._api_client.get_portal_url(self._session_id)
311
+ return response.data['portal_url']
312
+ except Exception as e:
313
+ raise AuthenticationError(f"Failed to get portal URL: {str(e)}")
314
+
315
+ async def get_session_user(self) -> Dict[str, Any]:
316
+ """Get the user and tokens for a completed session.
317
+
318
+ Returns:
319
+ Dict containing user_id, access_token, refresh_token, and other user info
320
+
321
+ Raises:
322
+ AuthenticationError: If session is not initialized or not completed
323
+ """
324
+ if not self._session_id:
325
+ raise AuthenticationError("Session not initialized. Call start_session() first.")
326
+
327
+ if not self._company_id:
328
+ raise AuthenticationError("Company ID not available. Session may not be properly initialized.")
329
+
330
+ try:
331
+ # Call the new endpoint with session ID as Bearer token and company ID header
332
+ response = await self._api_client.get_session_user(self._session_id, self._company_id)
333
+
334
+ # Store tokens internally for future API calls
335
+ self._store_tokens(response)
336
+
337
+ # Return user info using the getter methods
338
+ return {
339
+ "user_id": response.get_user_id(),
340
+ "access_token": response.get_access_token(),
341
+ "refresh_token": response.get_refresh_token(),
342
+ "expires_in": response.get_expires_in(),
343
+ "token_type": response.get_token_type(),
344
+ "scope": response.get_scope(),
345
+ "company_id": response.get_company_id()
346
+ }
347
+
348
+ except Exception as e:
349
+ raise AuthenticationError(f"Failed to get session user: {str(e)}")
350
+
351
+ def _store_tokens(self, user_response):
352
+ """Store tokens internally for automatic use in API calls."""
353
+ # Store in the API client for automatic token management
354
+ expires_at = datetime.now() + timedelta(seconds=user_response.get_expires_in())
355
+ self._api_client.set_tokens(
356
+ user_response.get_access_token(),
357
+ user_response.get_refresh_token(),
358
+ expires_at.isoformat(),
359
+ user_response.get_user_id()
360
+ )
361
+
362
+ # Also store in our local state
363
+ self._user_token = UserToken(
364
+ access_token=user_response.get_access_token(),
365
+ refresh_token=user_response.get_refresh_token(),
366
+ expires_in=user_response.get_expires_in(),
367
+ user_id=user_response.get_user_id(),
368
+ token_type=user_response.get_token_type(),
369
+ scope=user_response.get_scope()
370
+ )
371
+
372
+ def is_authenticated(self) -> bool:
373
+ """Check if the client is authenticated.
374
+
375
+ Returns:
376
+ True if authenticated, False otherwise
377
+ """
378
+ return (
379
+ self._user_token is not None and
380
+ self._user_token.access_token is not None and
381
+ self._user_token.refresh_token is not None and
382
+ self._user_token.user_id is not None
383
+ )
384
+
385
+ async def _ensure_authenticated(self):
386
+ """Ensure the client is authenticated.
387
+
388
+ Raises:
389
+ AuthenticationError: If not authenticated
390
+ """
391
+ if not self.is_authenticated():
392
+ raise AuthenticationError("Client not authenticated. Complete authentication flow first.")
393
+
394
+ # Portfolio methods
395
+ async def get_holdings(self) -> List[Holding]:
396
+ """Get portfolio holdings.
397
+
398
+ Returns:
399
+ List of holdings
400
+
401
+ Raises:
402
+ AuthenticationError: If not authenticated
403
+ ApiError: For other API errors
404
+ """
405
+ await self._ensure_authenticated()
406
+ access_token = await self._api_client.get_valid_access_token()
407
+
408
+ response = await self._api_client._request(
409
+ method='GET',
410
+ path='/portfolio/holdings',
411
+ access_token=access_token
412
+ )
413
+
414
+ return [Holding(**holding) for holding in response.get('data', [])]
415
+
416
+ async def get_portfolio(self) -> Portfolio:
417
+ """Get portfolio information.
418
+
419
+ Returns:
420
+ Portfolio information
421
+
422
+ Raises:
423
+ AuthenticationError: If not authenticated
424
+ ApiError: For other API errors
425
+ """
426
+ await self._ensure_authenticated()
427
+ access_token = await self._api_client.get_valid_access_token()
428
+
429
+ response = await self._api_client._request(
430
+ method='GET',
431
+ path='/portfolio',
432
+ access_token=access_token
433
+ )
434
+
435
+ return Portfolio(**response['data'])
436
+
437
+ async def get_orders(self) -> List[Order]:
438
+ """Get portfolio orders.
439
+
440
+ Returns:
441
+ List of orders
442
+
443
+ Raises:
444
+ AuthenticationError: If not authenticated
445
+ ApiError: For other API errors
446
+ """
447
+ await self._ensure_authenticated()
448
+ access_token = await self._api_client.get_valid_access_token()
449
+
450
+ response = await self._api_client._request(
451
+ method='GET',
452
+ path='/portfolio/orders',
453
+ access_token=access_token
454
+ )
455
+
456
+ return [Order(**order) for order in response.get('data', [])]
457
+
458
+ # Trading context methods
459
+ def set_broker(self, broker: str):
460
+ """Set the current broker."""
461
+ self._trading_context.broker = broker
462
+ self._api_client.set_broker(broker)
463
+
464
+ def set_account(self, account_number: str, account_id: Optional[str] = None):
465
+ """Set the current account."""
466
+ self._trading_context.account_number = account_number
467
+ self._trading_context.account_id = account_id
468
+ self._api_client.set_account(account_number, account_id)
469
+
470
+ def get_trading_context(self) -> TradingContext:
471
+ """Get the current trading context."""
472
+ return self._trading_context
473
+
474
+ def clear_trading_context(self):
475
+ """Clear the trading context."""
476
+ self._trading_context = TradingContext()
477
+ self._api_client.clear_trading_context()
478
+
479
+ # Utility methods
480
+ def get_user_id(self) -> Optional[str]:
481
+ """Get the current user ID."""
482
+ return self._user_token.user_id if self._user_token else None
483
+
484
+ def get_session_id(self) -> Optional[str]:
485
+ """Get the current session ID."""
486
+ return self._session_id
487
+
488
+ def get_company_id(self) -> Optional[str]:
489
+ """Get the current company ID."""
490
+ return self._company_id
491
+
492
+ def is_authed(self) -> bool:
493
+ """Return True if the client has a valid access and refresh token."""
494
+ token_info = self._api_client.token_info
495
+ if not token_info:
496
+ return False
497
+ access_token = token_info.get('access_token')
498
+ refresh_token = token_info.get('refresh_token')
499
+ expires_at = token_info.get('expires_at')
500
+ if not access_token or not refresh_token:
501
+ return False
502
+ # Optionally, check if access token is expired
503
+ if expires_at:
504
+ from datetime import datetime
505
+ try:
506
+ expires_dt = datetime.fromisoformat(expires_at)
507
+ if expires_dt < datetime.now():
508
+ return False
509
+ except Exception:
510
+ pass
511
+ return True
512
+
513
+ # Simple methods that automatically use stored tokens
514
+ async def get_holdings(self) -> List[Holding]:
515
+ """Get holdings using stored access token."""
516
+ return await self._api_client.get_holdings_auto()
517
+
518
+ async def get_orders(self) -> List[Order]:
519
+ """Get orders using stored access token."""
520
+ return await self._api_client.get_orders_auto()
521
+
522
+ async def get_portfolio(self) -> Portfolio:
523
+ """Get portfolio using stored access token."""
524
+ return await self._api_client.get_portfolio_auto()
525
+
526
+ async def get_broker_list(self) -> List[BrokerInfo]:
527
+ """Get broker list using stored access token."""
528
+ return await self._api_client.get_broker_list_auto()
529
+
530
+ async def get_broker_accounts(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[AccountsFilter] = None) -> PaginatedResult:
531
+ """Get broker accounts with pagination support."""
532
+ return await self._api_client.get_broker_accounts(page, per_page, options, filters)
533
+
534
+ async def get_broker_orders(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[OrdersFilter] = None) -> PaginatedResult:
535
+ """Get broker orders with pagination support."""
536
+ return await self._api_client.get_broker_orders(page, per_page, options, filters)
537
+
538
+ async def get_broker_positions(self, page: int = 1, per_page: int = 100, options: Optional[BrokerDataOptions] = None, filters: Optional[PositionsFilter] = None) -> PaginatedResult:
539
+ """Get broker positions with pagination support."""
540
+ return await self._api_client.get_broker_positions(page, per_page, options, filters)
541
+
542
+ async def get_broker_connections(self) -> List[BrokerConnection]:
543
+ """Get broker connections using stored access token."""
544
+ return await self._api_client.get_broker_connections_auto()
545
+
546
+ # Helper methods to get all data across pages
547
+ async def get_all_broker_accounts(self, options: Optional[BrokerDataOptions] = None, filters: Optional[AccountsFilter] = None) -> List[BrokerAccount]:
548
+ """Get all broker accounts across all pages."""
549
+ return await self._api_client.get_all_broker_accounts(options, filters)
550
+
551
+ async def get_all_broker_orders(self, options: Optional[BrokerDataOptions] = None, filters: Optional[OrdersFilter] = None) -> List[BrokerOrder]:
552
+ """Get all broker orders across all pages."""
553
+ return await self._api_client.get_all_broker_orders(options, filters)
554
+
555
+ async def get_all_broker_positions(self, options: Optional[BrokerDataOptions] = None, filters: Optional[PositionsFilter] = None) -> List[BrokerPosition]:
556
+ """Get all broker positions across all pages."""
557
+ return await self._api_client.get_all_broker_positions(options, filters)
558
+
559
+ async def close(self):
560
+ """Manually close the client and cleanup resources."""
561
+ try:
562
+ if hasattr(self, '_api_client') and self._api_client:
563
+ await self._api_client.__aexit__(None, None, None)
564
+ except Exception as e:
565
+ print(f"Warning: Error during client close: {e}")
566
+
567
+ def __del__(self):
568
+ """Destructor to ensure cleanup if context manager is not used."""
569
+ try:
570
+ if hasattr(self, '_api_client') and self._api_client:
571
+ # Try to close the session if it's still open
572
+ if hasattr(self._api_client, '_session') and self._api_client._session:
573
+ if not self._api_client._session.closed:
574
+ asyncio.create_task(self._api_client._session.close())
575
+ except Exception:
576
+ # Ignore cleanup errors in destructor
577
+ pass
@@ -0,0 +1,103 @@
1
+ """Type definitions and data models for the Finatic Server SDK."""
2
+
3
+ # Common types
4
+ from .common import (
5
+ DeviceInfo,
6
+ ApiResponse,
7
+ ApiPaginationInfo,
8
+ PaginationMetadata,
9
+ PaginatedResult,
10
+ TradingContext,
11
+ RequestHeaders,
12
+ )
13
+
14
+ # Authentication types
15
+ from .auth import (
16
+ UserToken,
17
+ SessionResponse,
18
+ SessionInitResponse,
19
+ OtpRequestResponse,
20
+ OtpVerifyResponse,
21
+ SessionAuthenticateResponse,
22
+ PortalUrlResponse,
23
+ SessionValidationResponse,
24
+ )
25
+
26
+ # Portfolio types
27
+ from .portfolio import (
28
+ Portfolio,
29
+ Holding,
30
+ PerformanceMetrics,
31
+ PortfolioSnapshot,
32
+ )
33
+
34
+ # Order types
35
+ from .orders import (
36
+ Order,
37
+ OptionsOrder,
38
+ CryptoOrderOptions,
39
+ OptionsOrderOptions,
40
+ OrderResponse,
41
+ BrokerOrderParams,
42
+ BrokerExtras,
43
+ )
44
+
45
+ # Broker types
46
+ from .broker import (
47
+ BrokerAccount,
48
+ BrokerOrder,
49
+ BrokerPosition,
50
+ BrokerInfo,
51
+ BrokerConnection,
52
+ BrokerDataOptions,
53
+ OrdersFilter,
54
+ PositionsFilter,
55
+ AccountsFilter,
56
+ )
57
+
58
+ __all__ = [
59
+ # Common
60
+ "DeviceInfo",
61
+ "ApiResponse",
62
+ "ApiPaginationInfo",
63
+ "PaginationMetadata",
64
+ "PaginatedResult",
65
+ "TradingContext",
66
+ "RequestHeaders",
67
+
68
+ # Auth
69
+ "UserToken",
70
+ "SessionResponse",
71
+ "SessionInitResponse",
72
+ "OtpRequestResponse",
73
+ "OtpVerifyResponse",
74
+ "SessionAuthenticateResponse",
75
+ "PortalUrlResponse",
76
+ "SessionValidationResponse",
77
+
78
+ # Portfolio
79
+ "Portfolio",
80
+ "Holding",
81
+ "PerformanceMetrics",
82
+ "PortfolioSnapshot",
83
+
84
+ # Orders
85
+ "Order",
86
+ "OptionsOrder",
87
+ "CryptoOrderOptions",
88
+ "OptionsOrderOptions",
89
+ "OrderResponse",
90
+ "BrokerOrderParams",
91
+ "BrokerExtras",
92
+
93
+ # Broker
94
+ "BrokerAccount",
95
+ "BrokerOrder",
96
+ "BrokerPosition",
97
+ "BrokerInfo",
98
+ "BrokerConnection",
99
+ "BrokerDataOptions",
100
+ "OrdersFilter",
101
+ "PositionsFilter",
102
+ "AccountsFilter",
103
+ ]