fin-infra 0.1.66__py3-none-any.whl → 0.1.68__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.
Files changed (50) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/portfolio.py +12 -18
  3. fin_infra/analytics/rebalancing.py +2 -4
  4. fin_infra/analytics/savings.py +1 -1
  5. fin_infra/analytics/spending.py +3 -1
  6. fin_infra/banking/history.py +3 -3
  7. fin_infra/banking/utils.py +88 -82
  8. fin_infra/brokerage/__init__.py +1 -1
  9. fin_infra/budgets/tracker.py +2 -3
  10. fin_infra/categorization/ease.py +2 -3
  11. fin_infra/categorization/llm_layer.py +2 -2
  12. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  13. fin_infra/credit/experian/provider.py +14 -14
  14. fin_infra/crypto/__init__.py +1 -1
  15. fin_infra/documents/add.py +4 -4
  16. fin_infra/documents/ease.py +4 -3
  17. fin_infra/documents/models.py +3 -3
  18. fin_infra/documents/ocr.py +1 -1
  19. fin_infra/documents/storage.py +2 -1
  20. fin_infra/exceptions.py +1 -1
  21. fin_infra/goals/management.py +3 -3
  22. fin_infra/insights/__init__.py +0 -1
  23. fin_infra/investments/__init__.py +2 -4
  24. fin_infra/investments/add.py +37 -56
  25. fin_infra/investments/ease.py +7 -8
  26. fin_infra/investments/models.py +29 -17
  27. fin_infra/investments/providers/base.py +3 -8
  28. fin_infra/investments/providers/plaid.py +19 -29
  29. fin_infra/investments/providers/snaptrade.py +18 -36
  30. fin_infra/markets/__init__.py +4 -2
  31. fin_infra/models/accounts.py +2 -1
  32. fin_infra/models/transactions.py +2 -1
  33. fin_infra/net_worth/calculator.py +8 -6
  34. fin_infra/net_worth/ease.py +2 -2
  35. fin_infra/net_worth/insights.py +4 -4
  36. fin_infra/normalization/__init__.py +3 -1
  37. fin_infra/providers/banking/plaid_client.py +16 -16
  38. fin_infra/providers/base.py +5 -5
  39. fin_infra/providers/brokerage/alpaca.py +2 -2
  40. fin_infra/providers/market/ccxt_crypto.py +4 -1
  41. fin_infra/recurring/add.py +3 -1
  42. fin_infra/recurring/detector.py +1 -1
  43. fin_infra/recurring/normalizer.py +1 -1
  44. fin_infra/scaffold/__init__.py +1 -1
  45. fin_infra/tax/__init__.py +1 -1
  46. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
  47. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
  48. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
  49. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
  50. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
@@ -113,7 +113,7 @@ def easy_categorization(
113
113
  if enable_llm:
114
114
  if LLMCategorizer is None:
115
115
  raise ImportError(
116
- "LLM support requires ai-infra package. " "Install with: pip install ai-infra"
116
+ "LLM support requires ai-infra package. Install with: pip install ai-infra"
117
117
  )
118
118
 
119
119
  # Map provider names to ai-infra provider format
@@ -125,8 +125,7 @@ def easy_categorization(
125
125
  ai_infra_provider = provider_map.get(llm_provider)
126
126
  if not ai_infra_provider:
127
127
  raise ValueError(
128
- f"Unsupported LLM provider: {llm_provider}. "
129
- f"Use 'google', 'openai', or 'anthropic'."
128
+ f"Unsupported LLM provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'."
130
129
  )
131
130
 
132
131
  # Default models per provider
@@ -284,8 +284,8 @@ class LLMCategorizer:
284
284
  Merchant: "{merchant_name}"
285
285
 
286
286
  User context:
287
- - Frequently shops at: {context['top_merchants']}
288
- - Top spending categories: {context['top_categories']}
287
+ - Frequently shops at: {context["top_merchants"]}
288
+ - Top spending categories: {context["top_categories"]}
289
289
 
290
290
  Return JSON with category, confidence, and reasoning."""
291
291
  else:
@@ -108,7 +108,7 @@ def cmd_scaffold(
108
108
  err=True,
109
109
  )
110
110
  raise typer.Exit(1)
111
-
111
+
112
112
  # Import scaffold function based on domain
113
113
  if domain == "budgets":
114
114
  from fin_infra.scaffold.budgets import scaffold_budgets_core
@@ -174,7 +174,7 @@ class ExperianProvider(CreditProvider):
174
174
  permissible_purpose = kwargs.get("permissible_purpose", "account_review")
175
175
  requester_ip = kwargs.get("requester_ip", "unknown")
176
176
  requester_user_id = kwargs.get("requester_user_id", "unknown")
177
-
177
+
178
178
  # FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
179
179
  # This log must be retained for at least 2 years per FCRA requirements
180
180
  timestamp = datetime.now(timezone.utc).isoformat()
@@ -190,7 +190,7 @@ class ExperianProvider(CreditProvider):
190
190
  "environment": self.environment,
191
191
  "timestamp": timestamp,
192
192
  "result": "pending",
193
- }
193
+ },
194
194
  )
195
195
 
196
196
  try:
@@ -202,7 +202,7 @@ class ExperianProvider(CreditProvider):
202
202
 
203
203
  # Parse response to CreditScore model
204
204
  result = parse_credit_score(data, user_id=user_id)
205
-
205
+
206
206
  # Log successful pull
207
207
  fcra_audit_logger.info(
208
208
  "FCRA_CREDIT_PULL_SUCCESS",
@@ -213,11 +213,11 @@ class ExperianProvider(CreditProvider):
213
213
  "timestamp": timestamp,
214
214
  "result": "success",
215
215
  "score_returned": result.score is not None,
216
- }
216
+ },
217
217
  )
218
-
218
+
219
219
  return result
220
-
220
+
221
221
  except Exception as e:
222
222
  # Log failed pull - still required for FCRA audit trail
223
223
  fcra_audit_logger.warning(
@@ -229,7 +229,7 @@ class ExperianProvider(CreditProvider):
229
229
  "timestamp": timestamp,
230
230
  "result": "error",
231
231
  "error_type": type(e).__name__,
232
- }
232
+ },
233
233
  )
234
234
  raise
235
235
 
@@ -262,7 +262,7 @@ class ExperianProvider(CreditProvider):
262
262
  permissible_purpose = kwargs.get("permissible_purpose", "account_review")
263
263
  requester_ip = kwargs.get("requester_ip", "unknown")
264
264
  requester_user_id = kwargs.get("requester_user_id", "unknown")
265
-
265
+
266
266
  # FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
267
267
  # Full credit report pulls have stricter requirements than score-only pulls
268
268
  # This log must be retained for at least 2 years per FCRA requirements
@@ -280,7 +280,7 @@ class ExperianProvider(CreditProvider):
280
280
  "timestamp": timestamp,
281
281
  "result": "pending",
282
282
  "report_type": "full",
283
- }
283
+ },
284
284
  )
285
285
 
286
286
  try:
@@ -292,7 +292,7 @@ class ExperianProvider(CreditProvider):
292
292
 
293
293
  # Parse response to CreditReport model
294
294
  result = parse_credit_report(data, user_id=user_id)
295
-
295
+
296
296
  # Log successful pull
297
297
  fcra_audit_logger.info(
298
298
  "FCRA_CREDIT_PULL_SUCCESS",
@@ -304,11 +304,11 @@ class ExperianProvider(CreditProvider):
304
304
  "result": "success",
305
305
  "accounts_returned": len(result.accounts) if result.accounts else 0,
306
306
  "inquiries_returned": len(result.inquiries) if result.inquiries else 0,
307
- }
307
+ },
308
308
  )
309
-
309
+
310
310
  return result
311
-
311
+
312
312
  except Exception as e:
313
313
  # Log failed pull - still required for FCRA audit trail
314
314
  fcra_audit_logger.warning(
@@ -320,7 +320,7 @@ class ExperianProvider(CreditProvider):
320
320
  "timestamp": timestamp,
321
321
  "result": "error",
322
322
  "error_type": type(e).__name__,
323
- }
323
+ },
324
324
  )
325
325
  raise
326
326
 
@@ -70,7 +70,7 @@ def easy_crypto(
70
70
  return CoinGeckoCryptoData()
71
71
 
72
72
  else:
73
- raise ValueError(f"Unknown crypto data provider: {provider_name}. " f"Supported: coingecko")
73
+ raise ValueError(f"Unknown crypto data provider: {provider_name}. Supported: coingecko")
74
74
 
75
75
 
76
76
  def add_crypto_data(
@@ -93,6 +93,7 @@ def add_documents(
93
93
  # Import svc-infra base function to mount base endpoints (with fallback)
94
94
  try:
95
95
  from svc_infra.documents import add_documents as add_base_documents
96
+
96
97
  HAS_SVC_INFRA_DOCUMENTS = True
97
98
  except ImportError:
98
99
  # Fallback for older svc-infra versions - skip base endpoints
@@ -109,11 +110,12 @@ def add_documents(
109
110
  else:
110
111
  # Legacy mode: mount basic endpoints inline (for svc-infra < 0.1.668)
111
112
  import warnings
113
+
112
114
  warnings.warn(
113
115
  "svc_infra.documents not found. Using legacy document endpoints. "
114
116
  "Please upgrade svc-infra to >=0.1.668 for full functionality.",
115
117
  DeprecationWarning,
116
- stacklevel=2
118
+ stacklevel=2,
117
119
  )
118
120
 
119
121
  # Step 2: Create financial document manager with OCR/AI capabilities
@@ -210,9 +212,7 @@ def add_documents(
210
212
  ```
211
213
  """
212
214
  try:
213
- return await manager.analyze(
214
- document_id=document_id, force_refresh=force_refresh
215
- )
215
+ return await manager.analyze(document_id=document_id, force_refresh=force_refresh)
216
216
  except ValueError as e:
217
217
  raise HTTPException(status_code=404, detail=str(e))
218
218
 
@@ -44,11 +44,12 @@ except ImportError:
44
44
  # Fallback for older svc-infra versions without documents module
45
45
  # This provides backward compatibility until svc-infra 0.1.668+ is published
46
46
  import warnings
47
+
47
48
  warnings.warn(
48
49
  "svc_infra.documents not found. Using legacy implementation. "
49
50
  "Please upgrade svc-infra to >=0.1.668 for layered architecture support.",
50
51
  DeprecationWarning,
51
- stacklevel=2
52
+ stacklevel=2,
52
53
  )
53
54
  BaseDocumentManager = object # type: ignore
54
55
 
@@ -65,10 +66,10 @@ class FinancialDocumentManager(BaseDocumentManager):
65
66
  Inherits from svc-infra DocumentManager:
66
67
  - upload(), download(), delete(), get(), list() for base document CRUD
67
68
  - storage backend integration
68
-
69
+
69
70
  Adds financial-specific methods:
70
71
  - upload_financial(): Upload with DocumentType, tax_year, form_type
71
- - extract_text(): OCR for tax forms
72
+ - extract_text(): OCR for tax forms
72
73
  - analyze(): AI-powered financial insights
73
74
 
74
75
  Attributes:
@@ -52,17 +52,17 @@ class DocumentType(str, Enum):
52
52
  class FinancialDocument(BaseDocument):
53
53
  """
54
54
  Financial document extending base Document with financial-specific fields.
55
-
55
+
56
56
  Inherits from svc-infra Document:
57
57
  - id, user_id, filename, file_size, upload_date
58
58
  - storage_path, content_type, checksum
59
59
  - metadata (Dict[str, Any])
60
-
60
+
61
61
  Adds financial-specific fields:
62
62
  - type: DocumentType enum
63
63
  - tax_year: Optional year for tax documents
64
64
  - form_type: Optional form identifier (W-2, 1099, etc.)
65
-
65
+
66
66
  Examples:
67
67
  >>> # Tax document with W-2 form
68
68
  >>> doc = FinancialDocument(
@@ -46,7 +46,7 @@ async def extract_text(
46
46
  Extract text from a document using OCR (uses svc-infra storage).
47
47
 
48
48
  Args:
49
- storage: Storage backend instance
49
+ storage: Storage backend instance
50
50
  document_id: Document identifier
51
51
  provider: OCR provider ("tesseract" or "textract")
52
52
  force_refresh: Force re-extraction even if cached result exists
@@ -46,6 +46,7 @@ try:
46
46
  list_documents as base_list_documents,
47
47
  upload_document as base_upload_document,
48
48
  )
49
+
49
50
  HAS_SVC_INFRA_DOCUMENTS = True
50
51
  except ImportError:
51
52
  # Fallback for older svc-infra versions - use legacy implementation
@@ -153,7 +154,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
153
154
  >>> doc = get_document("doc_abc123")
154
155
  >>> if doc:
155
156
  ... print(doc.filename, doc.type, doc.tax_year)
156
-
157
+
157
158
  Notes:
158
159
  - Delegates to svc-infra.documents.get_document
159
160
  - Converts base Document to FinancialDocument
fin_infra/exceptions.py CHANGED
@@ -6,7 +6,7 @@ This module provides a consistent exception hierarchy across all fin-infra compo
6
6
  - Validation errors (data validation, compliance)
7
7
  - Calculation errors (financial calculations)
8
8
 
9
- All exceptions inherit from FinInfraError, allowing users to catch all library
9
+ All exceptions inherit from FinInfraError, allowing users to catch all library
10
10
  errors with a single except clause.
11
11
 
12
12
  Example:
@@ -597,10 +597,10 @@ Goal type: {goal_type}
597
597
  Goal data: {goal}
598
598
 
599
599
  CALCULATED VALUES (use these exactly, don't recalculate):
600
- - Feasibility: {calc['feasibility']}
601
- - Required monthly: ${calc['required_monthly']:,.0f}
600
+ - Feasibility: {calc["feasibility"]}
601
+ - Required monthly: ${calc["required_monthly"]:,.0f}
602
602
  - Projected completion: {projected_date}
603
- - Current progress: {calc['current_progress']:.1%}
603
+ - Current progress: {calc["current_progress"]:.1%}
604
604
 
605
605
  Provide context and advice around these calculations. Suggest 2-3 alternative paths and 3-5 specific recommendations."""
606
606
 
@@ -126,4 +126,3 @@ def add_insights(
126
126
  app.include_router(router, include_in_schema=True)
127
127
 
128
128
  print("✅ Insights feed enabled (unified financial insights)")
129
-
@@ -19,7 +19,7 @@ Example usage:
19
19
  # Explicit provider
20
20
  investments = easy_investments(provider="plaid")
21
21
  holdings = await investments.get_holdings(access_token)
22
-
22
+
23
23
  # Calculate metrics
24
24
  allocation = investments.calculate_allocation(holdings)
25
25
  metrics = investments.calculate_portfolio_metrics(holdings)
@@ -125,9 +125,7 @@ def easy_investments(
125
125
 
126
126
  instance = SnapTradeInvestmentProvider(**config)
127
127
  else:
128
- raise ValueError(
129
- f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'"
130
- )
128
+ raise ValueError(f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'")
131
129
 
132
130
  _provider_cache[cache_key] = instance
133
131
  return instance
@@ -39,12 +39,8 @@ class HoldingsRequest(BaseModel):
39
39
 
40
40
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
41
41
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
42
- user_secret: Optional[str] = Field(
43
- None, description="SnapTrade user secret (SnapTrade only)"
44
- )
45
- account_ids: Optional[list[str]] = Field(
46
- None, description="Filter by specific account IDs"
47
- )
42
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
43
+ account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
48
44
 
49
45
 
50
46
  class TransactionsRequest(BaseModel):
@@ -52,14 +48,10 @@ class TransactionsRequest(BaseModel):
52
48
 
53
49
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
54
50
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
55
- user_secret: Optional[str] = Field(
56
- None, description="SnapTrade user secret (SnapTrade only)"
57
- )
51
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
58
52
  start_date: date = Field(..., description="Start date for transactions (YYYY-MM-DD)")
59
53
  end_date: date = Field(..., description="End date for transactions (YYYY-MM-DD)")
60
- account_ids: Optional[list[str]] = Field(
61
- None, description="Filter by specific account IDs"
62
- )
54
+ account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
63
55
 
64
56
 
65
57
  class AccountsRequest(BaseModel):
@@ -67,9 +59,7 @@ class AccountsRequest(BaseModel):
67
59
 
68
60
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
69
61
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
70
- user_secret: Optional[str] = Field(
71
- None, description="SnapTrade user secret (SnapTrade only)"
72
- )
62
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
73
63
 
74
64
 
75
65
  class AllocationRequest(BaseModel):
@@ -77,12 +67,8 @@ class AllocationRequest(BaseModel):
77
67
 
78
68
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
79
69
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
80
- user_secret: Optional[str] = Field(
81
- None, description="SnapTrade user secret (SnapTrade only)"
82
- )
83
- account_ids: Optional[list[str]] = Field(
84
- None, description="Filter by specific account IDs"
85
- )
70
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
71
+ account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
86
72
 
87
73
 
88
74
  class SecuritiesRequest(BaseModel):
@@ -90,9 +76,7 @@ class SecuritiesRequest(BaseModel):
90
76
 
91
77
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
92
78
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
93
- user_secret: Optional[str] = Field(
94
- None, description="SnapTrade user secret (SnapTrade only)"
95
- )
79
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
96
80
  security_ids: list[str] = Field(..., description="List of security IDs to retrieve")
97
81
 
98
82
 
@@ -155,13 +139,13 @@ def add_investments(
155
139
  - Validates user's JWT/session cookie
156
140
  - Ensures user is logged into YOUR application
157
141
  - Provides identity.user with authenticated user
158
-
142
+
159
143
  2. Provider Access (endpoint logic): Handled by these endpoints
160
144
  - Gets Plaid/SnapTrade access token for the provider
161
145
  - Auto-resolves from identity.user.banking_providers
162
146
  - Can be overridden with explicit token in request body
163
147
  - Used to call external provider APIs (Plaid, SnapTrade)
164
-
148
+
165
149
  POST requests are used (not GET) because:
166
150
  1. Provider credentials should not be in URL query parameters
167
151
  2. Request bodies are more suitable for sensitive data
@@ -173,7 +157,7 @@ def add_investments(
173
157
 
174
158
  # 2. Store on app state
175
159
  app.state.investment_provider = provider
176
-
160
+
177
161
  # 3. Capture provider in local variable for closure
178
162
  investment_provider = provider
179
163
 
@@ -204,20 +188,19 @@ def add_investments(
204
188
  access_token = f"{request.user_id}:{request.user_secret}"
205
189
  else:
206
190
  # Auto-resolve from authenticated user (user_router guarantees identity.user exists)
207
- banking_providers = getattr(identity.user, 'banking_providers', {})
191
+ banking_providers = getattr(identity.user, "banking_providers", {})
208
192
  if not banking_providers or "plaid" not in banking_providers:
209
193
  raise HTTPException(
210
194
  status_code=400,
211
- detail="No Plaid connection found. Please connect your accounts first."
195
+ detail="No Plaid connection found. Please connect your accounts first.",
212
196
  )
213
-
197
+
214
198
  access_token = banking_providers["plaid"].get("access_token")
215
199
  if not access_token:
216
200
  raise HTTPException(
217
- status_code=400,
218
- detail="No access token found. Please reconnect your accounts."
201
+ status_code=400, detail="No access token found. Please reconnect your accounts."
219
202
  )
220
-
203
+
221
204
  # Call provider with resolved token
222
205
  try:
223
206
  holdings = await investment_provider.get_holdings(
@@ -236,7 +219,9 @@ def add_investments(
236
219
  summary="List Transactions",
237
220
  description="Fetch investment transactions (buy, sell, dividend, etc.) within date range",
238
221
  )
239
- async def get_transactions(request: TransactionsRequest, identity: Identity) -> list[InvestmentTransaction]:
222
+ async def get_transactions(
223
+ request: TransactionsRequest, identity: Identity
224
+ ) -> list[InvestmentTransaction]:
240
225
  """
241
226
  Retrieve investment transactions for authenticated user's accounts.
242
227
 
@@ -256,18 +241,17 @@ def add_investments(
256
241
  elif request.user_id and request.user_secret:
257
242
  access_token = f"{request.user_id}:{request.user_secret}"
258
243
  else:
259
- banking_providers = getattr(identity.user, 'banking_providers', {})
244
+ banking_providers = getattr(identity.user, "banking_providers", {})
260
245
  if not banking_providers or "plaid" not in banking_providers:
261
246
  raise HTTPException(
262
247
  status_code=400,
263
- detail="No Plaid connection found. Please connect your accounts first."
248
+ detail="No Plaid connection found. Please connect your accounts first.",
264
249
  )
265
-
250
+
266
251
  access_token = banking_providers["plaid"].get("access_token")
267
252
  if not access_token:
268
253
  raise HTTPException(
269
- status_code=400,
270
- detail="No access token found. Please reconnect your accounts."
254
+ status_code=400, detail="No access token found. Please reconnect your accounts."
271
255
  )
272
256
 
273
257
  try:
@@ -301,18 +285,17 @@ def add_investments(
301
285
  elif request.user_id and request.user_secret:
302
286
  access_token = f"{request.user_id}:{request.user_secret}"
303
287
  else:
304
- banking_providers = getattr(identity.user, 'banking_providers', {})
288
+ banking_providers = getattr(identity.user, "banking_providers", {})
305
289
  if not banking_providers or "plaid" not in banking_providers:
306
290
  raise HTTPException(
307
291
  status_code=400,
308
- detail="No Plaid connection found. Please connect your accounts first."
292
+ detail="No Plaid connection found. Please connect your accounts first.",
309
293
  )
310
-
294
+
311
295
  access_token = banking_providers["plaid"].get("access_token")
312
296
  if not access_token:
313
297
  raise HTTPException(
314
- status_code=400,
315
- detail="No access token found. Please reconnect your accounts."
298
+ status_code=400, detail="No access token found. Please reconnect your accounts."
316
299
  )
317
300
 
318
301
  try:
@@ -343,20 +326,19 @@ def add_investments(
343
326
  elif request.user_id and request.user_secret:
344
327
  access_token = f"{request.user_id}:{request.user_secret}"
345
328
  else:
346
- banking_providers = getattr(identity.user, 'banking_providers', {})
329
+ banking_providers = getattr(identity.user, "banking_providers", {})
347
330
  if not banking_providers or "plaid" not in banking_providers:
348
331
  raise HTTPException(
349
332
  status_code=400,
350
- detail="No Plaid connection found. Please connect your accounts first."
333
+ detail="No Plaid connection found. Please connect your accounts first.",
351
334
  )
352
-
335
+
353
336
  access_token = banking_providers["plaid"].get("access_token")
354
337
  if not access_token:
355
338
  raise HTTPException(
356
- status_code=400,
357
- detail="No access token found. Please reconnect your accounts."
339
+ status_code=400, detail="No access token found. Please reconnect your accounts."
358
340
  )
359
-
341
+
360
342
  # Fetch holdings
361
343
  try:
362
344
  holdings = await investment_provider.get_holdings(
@@ -367,7 +349,7 @@ def add_investments(
367
349
  raise HTTPException(status_code=401, detail=str(e))
368
350
  except Exception as e:
369
351
  raise HTTPException(status_code=500, detail=f"Failed to fetch allocation: {e}")
370
-
352
+
371
353
  # Calculate allocation using base provider helper
372
354
  allocation = investment_provider.calculate_allocation(holdings)
373
355
  return allocation
@@ -390,18 +372,17 @@ def add_investments(
390
372
  elif request.user_id and request.user_secret:
391
373
  access_token = f"{request.user_id}:{request.user_secret}"
392
374
  else:
393
- banking_providers = getattr(identity.user, 'banking_providers', {})
375
+ banking_providers = getattr(identity.user, "banking_providers", {})
394
376
  if not banking_providers or "plaid" not in banking_providers:
395
377
  raise HTTPException(
396
378
  status_code=400,
397
- detail="No Plaid connection found. Please connect your accounts first."
379
+ detail="No Plaid connection found. Please connect your accounts first.",
398
380
  )
399
-
381
+
400
382
  access_token = banking_providers["plaid"].get("access_token")
401
383
  if not access_token:
402
384
  raise HTTPException(
403
- status_code=400,
404
- detail="No access token found. Please reconnect your accounts."
385
+ status_code=400, detail="No access token found. Please reconnect your accounts."
405
386
  )
406
387
 
407
388
  try:
@@ -24,13 +24,13 @@ def easy_investments(
24
24
 
25
25
  Provider Selection Guide:
26
26
  **Most apps should use BOTH providers for complete coverage:**
27
-
27
+
28
28
  - **Plaid**: Traditional investment accounts (401k, IRA, bank brokerage)
29
29
  - Coverage: 15,000+ institutions
30
30
  - Best for: Employer retirement accounts, bank-connected investments
31
31
  - Data freshness: Daily updates (usually overnight)
32
32
  - Authentication: access_token from Plaid Link
33
-
33
+
34
34
  - **SnapTrade**: Retail brokerage accounts (E*TRADE, Wealthsimple, Robinhood)
35
35
  - Coverage: 125M+ accounts, 70+ brokerages
36
36
  - Best for: User's EXISTING retail brokerage accounts
@@ -118,9 +118,7 @@ def easy_investments(
118
118
 
119
119
  # Validate provider
120
120
  if detected_provider not in ("plaid", "snaptrade"):
121
- raise ValueError(
122
- f"Invalid provider: {detected_provider}. Must be 'plaid' or 'snaptrade'."
123
- )
121
+ raise ValueError(f"Invalid provider: {detected_provider}. Must be 'plaid' or 'snaptrade'.")
124
122
 
125
123
  # Instantiate provider
126
124
  if detected_provider == "plaid":
@@ -193,8 +191,7 @@ def _create_plaid_provider(**config: Any) -> InvestmentProvider:
193
191
  valid_envs = ("sandbox", "development", "production")
194
192
  if environment not in valid_envs:
195
193
  raise ValueError(
196
- f"Invalid Plaid environment: {environment}. "
197
- f"Must be one of: {', '.join(valid_envs)}"
194
+ f"Invalid Plaid environment: {environment}. Must be one of: {', '.join(valid_envs)}"
198
195
  )
199
196
 
200
197
  return PlaidInvestmentProvider(
@@ -235,7 +232,9 @@ def _create_snaptrade_provider(**config: Any) -> InvestmentProvider:
235
232
  )
236
233
 
237
234
  # Ensure base_url is a string (default is set in SnapTradeInvestmentProvider)
238
- resolved_base_url: str = base_url if isinstance(base_url, str) else "https://api.snaptrade.com/api/v1"
235
+ resolved_base_url: str = (
236
+ base_url if isinstance(base_url, str) else "https://api.snaptrade.com/api/v1"
237
+ )
239
238
 
240
239
  return SnapTradeInvestmentProvider(
241
240
  client_id=client_id,