fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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 (83) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/cash_flow.py +6 -5
  3. fin_infra/analytics/portfolio.py +13 -20
  4. fin_infra/analytics/rebalancing.py +2 -4
  5. fin_infra/analytics/savings.py +1 -1
  6. fin_infra/analytics/spending.py +15 -11
  7. fin_infra/banking/__init__.py +8 -5
  8. fin_infra/banking/history.py +3 -3
  9. fin_infra/banking/utils.py +93 -88
  10. fin_infra/brokerage/__init__.py +5 -3
  11. fin_infra/budgets/tracker.py +2 -3
  12. fin_infra/cashflows/__init__.py +6 -8
  13. fin_infra/categorization/__init__.py +1 -1
  14. fin_infra/categorization/add.py +15 -16
  15. fin_infra/categorization/ease.py +3 -4
  16. fin_infra/categorization/engine.py +4 -4
  17. fin_infra/categorization/llm_layer.py +5 -6
  18. fin_infra/categorization/models.py +1 -1
  19. fin_infra/chat/__init__.py +7 -16
  20. fin_infra/chat/planning.py +57 -0
  21. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  22. fin_infra/compliance/__init__.py +3 -3
  23. fin_infra/credit/add.py +3 -2
  24. fin_infra/credit/experian/auth.py +3 -2
  25. fin_infra/credit/experian/client.py +2 -2
  26. fin_infra/credit/experian/provider.py +16 -16
  27. fin_infra/crypto/__init__.py +1 -1
  28. fin_infra/crypto/insights.py +1 -3
  29. fin_infra/documents/add.py +5 -5
  30. fin_infra/documents/ease.py +4 -3
  31. fin_infra/documents/models.py +3 -3
  32. fin_infra/documents/ocr.py +1 -1
  33. fin_infra/documents/storage.py +2 -1
  34. fin_infra/exceptions.py +1 -1
  35. fin_infra/goals/add.py +2 -2
  36. fin_infra/goals/management.py +6 -6
  37. fin_infra/goals/milestones.py +2 -2
  38. fin_infra/insights/__init__.py +7 -8
  39. fin_infra/investments/__init__.py +13 -8
  40. fin_infra/investments/add.py +39 -59
  41. fin_infra/investments/ease.py +16 -13
  42. fin_infra/investments/models.py +130 -64
  43. fin_infra/investments/providers/base.py +3 -8
  44. fin_infra/investments/providers/plaid.py +23 -34
  45. fin_infra/investments/providers/snaptrade.py +22 -40
  46. fin_infra/markets/__init__.py +11 -8
  47. fin_infra/models/accounts.py +2 -1
  48. fin_infra/models/transactions.py +3 -2
  49. fin_infra/net_worth/add.py +8 -5
  50. fin_infra/net_worth/aggregator.py +5 -4
  51. fin_infra/net_worth/calculator.py +8 -6
  52. fin_infra/net_worth/ease.py +36 -15
  53. fin_infra/net_worth/insights.py +4 -4
  54. fin_infra/net_worth/models.py +237 -116
  55. fin_infra/normalization/__init__.py +15 -13
  56. fin_infra/normalization/providers/exchangerate.py +3 -3
  57. fin_infra/obs/classifier.py +2 -2
  58. fin_infra/providers/banking/plaid_client.py +20 -19
  59. fin_infra/providers/banking/teller_client.py +13 -7
  60. fin_infra/providers/base.py +105 -13
  61. fin_infra/providers/brokerage/alpaca.py +7 -7
  62. fin_infra/providers/credit/experian.py +5 -0
  63. fin_infra/providers/market/ccxt_crypto.py +8 -3
  64. fin_infra/providers/tax/mock.py +3 -3
  65. fin_infra/recurring/add.py +20 -9
  66. fin_infra/recurring/detector.py +1 -1
  67. fin_infra/recurring/detectors_llm.py +10 -9
  68. fin_infra/recurring/ease.py +1 -1
  69. fin_infra/recurring/insights.py +9 -8
  70. fin_infra/recurring/models.py +3 -3
  71. fin_infra/recurring/normalizer.py +3 -2
  72. fin_infra/recurring/normalizers.py +9 -8
  73. fin_infra/scaffold/__init__.py +1 -1
  74. fin_infra/security/encryption.py +2 -2
  75. fin_infra/security/pii_patterns.py +1 -1
  76. fin_infra/security/token_store.py +3 -1
  77. fin_infra/tax/__init__.py +1 -1
  78. fin_infra/utils/http.py +3 -2
  79. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
  80. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
  81. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
  82. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
  83. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
@@ -10,6 +10,11 @@ Aggregates insights from multiple sources:
10
10
  - Cash flow projections
11
11
  """
12
12
 
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from fastapi import FastAPI
17
+
13
18
  from .models import Insight, InsightFeed, InsightPriority, InsightCategory
14
19
  from .aggregator import aggregate_insights, get_user_insights
15
20
 
@@ -25,7 +30,7 @@ __all__ = [
25
30
 
26
31
 
27
32
  def add_insights(
28
- app: "FastAPI", # type: ignore
33
+ app: "FastAPI",
29
34
  *,
30
35
  prefix: str = "/insights",
31
36
  ) -> None:
@@ -71,11 +76,6 @@ def add_insights(
71
76
  - Real-time aggregation from net worth, budgets, goals, etc.
72
77
  - Notification system for critical insights
73
78
  """
74
- from typing import TYPE_CHECKING
75
-
76
- if TYPE_CHECKING:
77
- from fastapi import FastAPI
78
-
79
79
  from fastapi import Query
80
80
 
81
81
  # Import svc-infra user router (requires auth)
@@ -125,5 +125,4 @@ def add_insights(
125
125
  # Mount router
126
126
  app.include_router(router, include_in_schema=True)
127
127
 
128
- print(f"✅ Insights feed enabled (unified financial insights)")
129
-
128
+ print("✅ Insights feed enabled (unified financial insights)")
@@ -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)
@@ -51,7 +51,8 @@ from typing import TYPE_CHECKING, Literal
51
51
  if TYPE_CHECKING:
52
52
  from fastapi import FastAPI
53
53
 
54
- from ..providers.base import InvestmentProvider
54
+ # Use the local InvestmentProvider base class (same as providers use)
55
+ from .providers.base import InvestmentProvider
55
56
 
56
57
  # Lazy imports to avoid loading provider SDKs unless needed
57
58
  _provider_cache: dict[str, InvestmentProvider] = {}
@@ -114,6 +115,7 @@ def easy_investments(
114
115
  return _provider_cache[cache_key]
115
116
 
116
117
  # Lazy import and initialize provider
118
+ instance: InvestmentProvider
117
119
  if provider == "plaid":
118
120
  from .providers.plaid import PlaidInvestmentProvider
119
121
 
@@ -123,9 +125,7 @@ def easy_investments(
123
125
 
124
126
  instance = SnapTradeInvestmentProvider(**config)
125
127
  else:
126
- raise ValueError(
127
- f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'"
128
- )
128
+ raise ValueError(f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'")
129
129
 
130
130
  _provider_cache[cache_key] = instance
131
131
  return instance
@@ -172,14 +172,19 @@ def add_investments(
172
172
  >>> # GET /investments/transactions
173
173
  >>> # etc.
174
174
  """
175
- from .add import add_investments_impl
175
+ from .add import add_investments as add_investments_impl
176
+ from .providers.base import InvestmentProvider as InvestmentProviderBase
177
+
178
+ # Resolve provider from string Literal to actual InvestmentProvider instance
179
+ resolved_provider: InvestmentProviderBase | None = None
180
+ if provider is not None:
181
+ resolved_provider = easy_investments(provider=provider, **provider_config)
176
182
 
177
183
  return add_investments_impl(
178
184
  app,
179
- provider=provider,
185
+ provider=resolved_provider,
180
186
  prefix=prefix,
181
187
  tags=tags or ["Investments"],
182
- **provider_config,
183
188
  )
184
189
 
185
190
 
@@ -7,14 +7,13 @@ transactions, accounts, allocation, and securities data.
7
7
  from __future__ import annotations
8
8
 
9
9
  from datetime import date
10
- from typing import TYPE_CHECKING, Optional, Literal, Annotated
10
+ from typing import TYPE_CHECKING, Optional
11
11
 
12
- from fastapi import HTTPException, Query, Depends
12
+ from fastapi import HTTPException
13
13
  from pydantic import BaseModel, Field
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from fastapi import FastAPI
17
- from svc_infra.api.fastapi.auth.security import Principal
18
17
 
19
18
  # Import Identity for dependency injection
20
19
  try:
@@ -40,12 +39,8 @@ class HoldingsRequest(BaseModel):
40
39
 
41
40
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
42
41
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
43
- user_secret: Optional[str] = Field(
44
- None, description="SnapTrade user secret (SnapTrade only)"
45
- )
46
- account_ids: Optional[list[str]] = Field(
47
- None, description="Filter by specific account IDs"
48
- )
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")
49
44
 
50
45
 
51
46
  class TransactionsRequest(BaseModel):
@@ -53,14 +48,10 @@ class TransactionsRequest(BaseModel):
53
48
 
54
49
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
55
50
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
56
- user_secret: Optional[str] = Field(
57
- None, description="SnapTrade user secret (SnapTrade only)"
58
- )
51
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
59
52
  start_date: date = Field(..., description="Start date for transactions (YYYY-MM-DD)")
60
53
  end_date: date = Field(..., description="End date for transactions (YYYY-MM-DD)")
61
- account_ids: Optional[list[str]] = Field(
62
- None, description="Filter by specific account IDs"
63
- )
54
+ account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
64
55
 
65
56
 
66
57
  class AccountsRequest(BaseModel):
@@ -68,9 +59,7 @@ class AccountsRequest(BaseModel):
68
59
 
69
60
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
70
61
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
71
- user_secret: Optional[str] = Field(
72
- None, description="SnapTrade user secret (SnapTrade only)"
73
- )
62
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
74
63
 
75
64
 
76
65
  class AllocationRequest(BaseModel):
@@ -78,12 +67,8 @@ class AllocationRequest(BaseModel):
78
67
 
79
68
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
80
69
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
81
- user_secret: Optional[str] = Field(
82
- None, description="SnapTrade user secret (SnapTrade only)"
83
- )
84
- account_ids: Optional[list[str]] = Field(
85
- None, description="Filter by specific account IDs"
86
- )
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")
87
72
 
88
73
 
89
74
  class SecuritiesRequest(BaseModel):
@@ -91,9 +76,7 @@ class SecuritiesRequest(BaseModel):
91
76
 
92
77
  access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
93
78
  user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
94
- user_secret: Optional[str] = Field(
95
- None, description="SnapTrade user secret (SnapTrade only)"
96
- )
79
+ user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
97
80
  security_ids: list[str] = Field(..., description="List of security IDs to retrieve")
98
81
 
99
82
 
@@ -156,13 +139,13 @@ def add_investments(
156
139
  - Validates user's JWT/session cookie
157
140
  - Ensures user is logged into YOUR application
158
141
  - Provides identity.user with authenticated user
159
-
142
+
160
143
  2. Provider Access (endpoint logic): Handled by these endpoints
161
144
  - Gets Plaid/SnapTrade access token for the provider
162
145
  - Auto-resolves from identity.user.banking_providers
163
146
  - Can be overridden with explicit token in request body
164
147
  - Used to call external provider APIs (Plaid, SnapTrade)
165
-
148
+
166
149
  POST requests are used (not GET) because:
167
150
  1. Provider credentials should not be in URL query parameters
168
151
  2. Request bodies are more suitable for sensitive data
@@ -174,7 +157,7 @@ def add_investments(
174
157
 
175
158
  # 2. Store on app state
176
159
  app.state.investment_provider = provider
177
-
160
+
178
161
  # 3. Capture provider in local variable for closure
179
162
  investment_provider = provider
180
163
 
@@ -205,20 +188,19 @@ def add_investments(
205
188
  access_token = f"{request.user_id}:{request.user_secret}"
206
189
  else:
207
190
  # Auto-resolve from authenticated user (user_router guarantees identity.user exists)
208
- banking_providers = getattr(identity.user, 'banking_providers', {})
191
+ banking_providers = getattr(identity.user, "banking_providers", {})
209
192
  if not banking_providers or "plaid" not in banking_providers:
210
193
  raise HTTPException(
211
194
  status_code=400,
212
- detail="No Plaid connection found. Please connect your accounts first."
195
+ detail="No Plaid connection found. Please connect your accounts first.",
213
196
  )
214
-
197
+
215
198
  access_token = banking_providers["plaid"].get("access_token")
216
199
  if not access_token:
217
200
  raise HTTPException(
218
- status_code=400,
219
- detail="No access token found. Please reconnect your accounts."
201
+ status_code=400, detail="No access token found. Please reconnect your accounts."
220
202
  )
221
-
203
+
222
204
  # Call provider with resolved token
223
205
  try:
224
206
  holdings = await investment_provider.get_holdings(
@@ -237,7 +219,9 @@ def add_investments(
237
219
  summary="List Transactions",
238
220
  description="Fetch investment transactions (buy, sell, dividend, etc.) within date range",
239
221
  )
240
- async def get_transactions(request: TransactionsRequest, identity: Identity) -> list[InvestmentTransaction]:
222
+ async def get_transactions(
223
+ request: TransactionsRequest, identity: Identity
224
+ ) -> list[InvestmentTransaction]:
241
225
  """
242
226
  Retrieve investment transactions for authenticated user's accounts.
243
227
 
@@ -257,18 +241,17 @@ def add_investments(
257
241
  elif request.user_id and request.user_secret:
258
242
  access_token = f"{request.user_id}:{request.user_secret}"
259
243
  else:
260
- banking_providers = getattr(identity.user, 'banking_providers', {})
244
+ banking_providers = getattr(identity.user, "banking_providers", {})
261
245
  if not banking_providers or "plaid" not in banking_providers:
262
246
  raise HTTPException(
263
247
  status_code=400,
264
- detail="No Plaid connection found. Please connect your accounts first."
248
+ detail="No Plaid connection found. Please connect your accounts first.",
265
249
  )
266
-
250
+
267
251
  access_token = banking_providers["plaid"].get("access_token")
268
252
  if not access_token:
269
253
  raise HTTPException(
270
- status_code=400,
271
- detail="No access token found. Please reconnect your accounts."
254
+ status_code=400, detail="No access token found. Please reconnect your accounts."
272
255
  )
273
256
 
274
257
  try:
@@ -302,18 +285,17 @@ def add_investments(
302
285
  elif request.user_id and request.user_secret:
303
286
  access_token = f"{request.user_id}:{request.user_secret}"
304
287
  else:
305
- banking_providers = getattr(identity.user, 'banking_providers', {})
288
+ banking_providers = getattr(identity.user, "banking_providers", {})
306
289
  if not banking_providers or "plaid" not in banking_providers:
307
290
  raise HTTPException(
308
291
  status_code=400,
309
- detail="No Plaid connection found. Please connect your accounts first."
292
+ detail="No Plaid connection found. Please connect your accounts first.",
310
293
  )
311
-
294
+
312
295
  access_token = banking_providers["plaid"].get("access_token")
313
296
  if not access_token:
314
297
  raise HTTPException(
315
- status_code=400,
316
- detail="No access token found. Please reconnect your accounts."
298
+ status_code=400, detail="No access token found. Please reconnect your accounts."
317
299
  )
318
300
 
319
301
  try:
@@ -344,20 +326,19 @@ def add_investments(
344
326
  elif request.user_id and request.user_secret:
345
327
  access_token = f"{request.user_id}:{request.user_secret}"
346
328
  else:
347
- banking_providers = getattr(identity.user, 'banking_providers', {})
329
+ banking_providers = getattr(identity.user, "banking_providers", {})
348
330
  if not banking_providers or "plaid" not in banking_providers:
349
331
  raise HTTPException(
350
332
  status_code=400,
351
- detail="No Plaid connection found. Please connect your accounts first."
333
+ detail="No Plaid connection found. Please connect your accounts first.",
352
334
  )
353
-
335
+
354
336
  access_token = banking_providers["plaid"].get("access_token")
355
337
  if not access_token:
356
338
  raise HTTPException(
357
- status_code=400,
358
- detail="No access token found. Please reconnect your accounts."
339
+ status_code=400, detail="No access token found. Please reconnect your accounts."
359
340
  )
360
-
341
+
361
342
  # Fetch holdings
362
343
  try:
363
344
  holdings = await investment_provider.get_holdings(
@@ -368,7 +349,7 @@ def add_investments(
368
349
  raise HTTPException(status_code=401, detail=str(e))
369
350
  except Exception as e:
370
351
  raise HTTPException(status_code=500, detail=f"Failed to fetch allocation: {e}")
371
-
352
+
372
353
  # Calculate allocation using base provider helper
373
354
  allocation = investment_provider.calculate_allocation(holdings)
374
355
  return allocation
@@ -391,18 +372,17 @@ def add_investments(
391
372
  elif request.user_id and request.user_secret:
392
373
  access_token = f"{request.user_id}:{request.user_secret}"
393
374
  else:
394
- banking_providers = getattr(identity.user, 'banking_providers', {})
375
+ banking_providers = getattr(identity.user, "banking_providers", {})
395
376
  if not banking_providers or "plaid" not in banking_providers:
396
377
  raise HTTPException(
397
378
  status_code=400,
398
- detail="No Plaid connection found. Please connect your accounts first."
379
+ detail="No Plaid connection found. Please connect your accounts first.",
399
380
  )
400
-
381
+
401
382
  access_token = banking_providers["plaid"].get("access_token")
402
383
  if not access_token:
403
384
  raise HTTPException(
404
- status_code=400,
405
- detail="No access token found. Please reconnect your accounts."
385
+ status_code=400, detail="No access token found. Please reconnect your accounts."
406
386
  )
407
387
 
408
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
@@ -112,19 +112,18 @@ def easy_investments(
112
112
  - Most other SnapTrade brokerages support trading operations
113
113
  """
114
114
  # Auto-detect provider from environment if not specified
115
- if provider is None:
116
- provider = _detect_provider()
115
+ detected_provider: str | None = provider
116
+ if detected_provider is None:
117
+ detected_provider = _detect_provider()
117
118
 
118
119
  # Validate provider
119
- if provider not in ("plaid", "snaptrade"):
120
- raise ValueError(
121
- f"Invalid provider: {provider}. Must be 'plaid' or 'snaptrade'."
122
- )
120
+ if detected_provider not in ("plaid", "snaptrade"):
121
+ raise ValueError(f"Invalid provider: {detected_provider}. Must be 'plaid' or 'snaptrade'.")
123
122
 
124
123
  # Instantiate provider
125
- if provider == "plaid":
124
+ if detected_provider == "plaid":
126
125
  return _create_plaid_provider(**config)
127
- elif provider == "snaptrade":
126
+ elif detected_provider == "snaptrade":
128
127
  return _create_snaptrade_provider(**config)
129
128
 
130
129
  # Should never reach here
@@ -192,8 +191,7 @@ def _create_plaid_provider(**config: Any) -> InvestmentProvider:
192
191
  valid_envs = ("sandbox", "development", "production")
193
192
  if environment not in valid_envs:
194
193
  raise ValueError(
195
- f"Invalid Plaid environment: {environment}. "
196
- f"Must be one of: {', '.join(valid_envs)}"
194
+ f"Invalid Plaid environment: {environment}. Must be one of: {', '.join(valid_envs)}"
197
195
  )
198
196
 
199
197
  return PlaidInvestmentProvider(
@@ -233,10 +231,15 @@ def _create_snaptrade_provider(**config: Any) -> InvestmentProvider:
233
231
  "Example: easy_investments(provider='snaptrade', client_id='...', consumer_key='...')"
234
232
  )
235
233
 
234
+ # Ensure base_url is a string (default is set in SnapTradeInvestmentProvider)
235
+ resolved_base_url: str = (
236
+ base_url if isinstance(base_url, str) else "https://api.snaptrade.com/api/v1"
237
+ )
238
+
236
239
  return SnapTradeInvestmentProvider(
237
240
  client_id=client_id,
238
241
  consumer_key=consumer_key,
239
- base_url=base_url,
242
+ base_url=resolved_base_url,
240
243
  )
241
244
 
242
245