fin-infra 0.1.62__py3-none-any.whl → 0.1.82__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 (126) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +30 -32
  4. fin_infra/analytics/cash_flow.py +6 -5
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/portfolio.py +19 -26
  7. fin_infra/analytics/projections.py +1 -3
  8. fin_infra/analytics/rebalancing.py +2 -4
  9. fin_infra/analytics/savings.py +1 -1
  10. fin_infra/analytics/spending.py +15 -11
  11. fin_infra/banking/__init__.py +33 -31
  12. fin_infra/banking/history.py +11 -12
  13. fin_infra/banking/utils.py +116 -110
  14. fin_infra/brokerage/__init__.py +27 -27
  15. fin_infra/budgets/__init__.py +3 -3
  16. fin_infra/budgets/add.py +16 -17
  17. fin_infra/budgets/alerts.py +3 -3
  18. fin_infra/budgets/tracker.py +4 -5
  19. fin_infra/cashflows/__init__.py +8 -10
  20. fin_infra/cashflows/core.py +1 -1
  21. fin_infra/categorization/__init__.py +1 -1
  22. fin_infra/categorization/add.py +17 -19
  23. fin_infra/categorization/ease.py +3 -4
  24. fin_infra/categorization/engine.py +21 -18
  25. fin_infra/categorization/llm_layer.py +10 -10
  26. fin_infra/categorization/models.py +1 -1
  27. fin_infra/categorization/rules.py +2 -4
  28. fin_infra/categorization/taxonomy.py +2 -2
  29. fin_infra/chat/__init__.py +13 -22
  30. fin_infra/chat/planning.py +57 -1
  31. fin_infra/cli/cmds/scaffold_cmds.py +11 -12
  32. fin_infra/clients/__init__.py +23 -1
  33. fin_infra/clients/base.py +1 -1
  34. fin_infra/clients/plaid.py +2 -2
  35. fin_infra/compliance/__init__.py +7 -6
  36. fin_infra/credit/add.py +7 -7
  37. fin_infra/credit/experian/auth.py +3 -2
  38. fin_infra/credit/experian/client.py +2 -2
  39. fin_infra/credit/experian/provider.py +19 -19
  40. fin_infra/crypto/__init__.py +8 -10
  41. fin_infra/crypto/insights.py +5 -6
  42. fin_infra/documents/add.py +11 -13
  43. fin_infra/documents/analysis.py +9 -9
  44. fin_infra/documents/ease.py +18 -17
  45. fin_infra/documents/models.py +7 -7
  46. fin_infra/documents/ocr.py +8 -8
  47. fin_infra/documents/storage.py +23 -14
  48. fin_infra/exceptions.py +1 -2
  49. fin_infra/goals/__init__.py +8 -8
  50. fin_infra/goals/add.py +36 -36
  51. fin_infra/goals/funding.py +4 -6
  52. fin_infra/goals/management.py +6 -7
  53. fin_infra/goals/milestones.py +2 -3
  54. fin_infra/goals/models.py +7 -11
  55. fin_infra/insights/__init__.py +12 -10
  56. fin_infra/insights/aggregator.py +1 -1
  57. fin_infra/investments/__init__.py +14 -9
  58. fin_infra/investments/add.py +53 -73
  59. fin_infra/investments/ease.py +16 -13
  60. fin_infra/investments/models.py +135 -69
  61. fin_infra/investments/providers/base.py +9 -15
  62. fin_infra/investments/providers/plaid.py +70 -55
  63. fin_infra/investments/providers/snaptrade.py +35 -53
  64. fin_infra/markets/__init__.py +16 -11
  65. fin_infra/models/__init__.py +10 -10
  66. fin_infra/models/accounts.py +2 -1
  67. fin_infra/models/brokerage.py +2 -1
  68. fin_infra/models/candle.py +1 -0
  69. fin_infra/models/money.py +1 -0
  70. fin_infra/models/quotes.py +4 -3
  71. fin_infra/models/tax.py +2 -1
  72. fin_infra/models/transactions.py +4 -4
  73. fin_infra/net_worth/__init__.py +7 -0
  74. fin_infra/net_worth/add.py +8 -5
  75. fin_infra/net_worth/aggregator.py +9 -6
  76. fin_infra/net_worth/calculator.py +8 -6
  77. fin_infra/net_worth/ease.py +36 -15
  78. fin_infra/net_worth/insights.py +4 -5
  79. fin_infra/net_worth/models.py +237 -116
  80. fin_infra/normalization/__init__.py +17 -15
  81. fin_infra/normalization/providers/exchangerate.py +5 -5
  82. fin_infra/obs/classifier.py +3 -3
  83. fin_infra/providers/banking/plaid_client.py +23 -22
  84. fin_infra/providers/banking/teller_client.py +14 -7
  85. fin_infra/providers/base.py +131 -14
  86. fin_infra/providers/brokerage/alpaca.py +7 -7
  87. fin_infra/providers/credit/experian.py +5 -0
  88. fin_infra/providers/market/alphavantage.py +6 -11
  89. fin_infra/providers/market/ccxt_crypto.py +25 -4
  90. fin_infra/providers/market/coingecko.py +5 -6
  91. fin_infra/providers/market/yahoo.py +23 -8
  92. fin_infra/providers/tax/__init__.py +1 -1
  93. fin_infra/providers/tax/irs.py +1 -1
  94. fin_infra/providers/tax/mock.py +8 -8
  95. fin_infra/providers/tax/taxbit.py +1 -1
  96. fin_infra/recurring/__init__.py +6 -6
  97. fin_infra/recurring/add.py +24 -12
  98. fin_infra/recurring/detector.py +8 -8
  99. fin_infra/recurring/detectors_llm.py +14 -13
  100. fin_infra/recurring/ease.py +3 -5
  101. fin_infra/recurring/insights.py +20 -19
  102. fin_infra/recurring/models.py +3 -3
  103. fin_infra/recurring/normalizer.py +3 -2
  104. fin_infra/recurring/normalizers.py +11 -10
  105. fin_infra/recurring/summary.py +13 -15
  106. fin_infra/scaffold/__init__.py +1 -1
  107. fin_infra/scaffold/budgets.py +9 -9
  108. fin_infra/scaffold/goals.py +5 -5
  109. fin_infra/security/__init__.py +8 -8
  110. fin_infra/security/encryption.py +6 -6
  111. fin_infra/security/models.py +7 -7
  112. fin_infra/security/pii_filter.py +6 -6
  113. fin_infra/security/pii_patterns.py +1 -1
  114. fin_infra/security/token_store.py +3 -1
  115. fin_infra/settings.py +2 -1
  116. fin_infra/tax/__init__.py +2 -2
  117. fin_infra/tax/add.py +3 -2
  118. fin_infra/tax/tlh.py +5 -5
  119. fin_infra/utils/http.py +5 -3
  120. fin_infra/utils/retry.py +2 -1
  121. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
  122. fin_infra-0.1.82.dist-info/RECORD +180 -0
  123. fin_infra-0.1.62.dist-info/RECORD +0 -180
  124. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  125. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  126. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
@@ -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
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:
@@ -25,10 +24,10 @@ except ImportError:
25
24
 
26
25
  from .ease import easy_investments
27
26
  from .models import (
27
+ AssetAllocation,
28
28
  Holding,
29
- InvestmentTransaction,
30
29
  InvestmentAccount,
31
- AssetAllocation,
30
+ InvestmentTransaction,
32
31
  Security,
33
32
  )
34
33
  from .providers.base import InvestmentProvider
@@ -38,71 +37,55 @@ from .providers.base import InvestmentProvider
38
37
  class HoldingsRequest(BaseModel):
39
38
  """Request model for holdings endpoint."""
40
39
 
41
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
42
- 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
- )
40
+ access_token: str | None = Field(None, description="Plaid access token (Plaid only)")
41
+ user_id: str | None = Field(None, description="SnapTrade user ID (SnapTrade only)")
42
+ user_secret: str | None = Field(None, description="SnapTrade user secret (SnapTrade only)")
43
+ account_ids: list[str] | None = Field(None, description="Filter by specific account IDs")
49
44
 
50
45
 
51
46
  class TransactionsRequest(BaseModel):
52
47
  """Request model for transactions endpoint."""
53
48
 
54
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
55
- 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
- )
49
+ access_token: str | None = Field(None, description="Plaid access token (Plaid only)")
50
+ user_id: str | None = Field(None, description="SnapTrade user ID (SnapTrade only)")
51
+ user_secret: str | None = 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: list[str] | None = Field(None, description="Filter by specific account IDs")
64
55
 
65
56
 
66
57
  class AccountsRequest(BaseModel):
67
58
  """Request model for investment accounts endpoint."""
68
59
 
69
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
70
- 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
- )
60
+ access_token: str | None = Field(None, description="Plaid access token (Plaid only)")
61
+ user_id: str | None = Field(None, description="SnapTrade user ID (SnapTrade only)")
62
+ user_secret: str | None = Field(None, description="SnapTrade user secret (SnapTrade only)")
74
63
 
75
64
 
76
65
  class AllocationRequest(BaseModel):
77
66
  """Request model for asset allocation endpoint."""
78
67
 
79
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
80
- 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
- )
68
+ access_token: str | None = Field(None, description="Plaid access token (Plaid only)")
69
+ user_id: str | None = Field(None, description="SnapTrade user ID (SnapTrade only)")
70
+ user_secret: str | None = Field(None, description="SnapTrade user secret (SnapTrade only)")
71
+ account_ids: list[str] | None = Field(None, description="Filter by specific account IDs")
87
72
 
88
73
 
89
74
  class SecuritiesRequest(BaseModel):
90
75
  """Request model for securities endpoint."""
91
76
 
92
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
93
- 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
- )
77
+ access_token: str | None = Field(None, description="Plaid access token (Plaid only)")
78
+ user_id: str | None = Field(None, description="SnapTrade user ID (SnapTrade only)")
79
+ user_secret: str | None = 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
 
100
83
  def add_investments(
101
84
  app: FastAPI,
102
85
  prefix: str = "/investments",
103
- provider: Optional[InvestmentProvider] = None,
86
+ provider: InvestmentProvider | None = None,
104
87
  include_in_schema: bool = True,
105
- tags: Optional[list[str]] = None,
88
+ tags: list[str] | None = None,
106
89
  ) -> InvestmentProvider:
107
90
  """Add investment endpoints to FastAPI application.
108
91
 
@@ -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