fin-infra 0.1.69__py3-none-any.whl → 0.4.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.
Files changed (131) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +24 -24
  4. fin_infra/analytics/cash_flow.py +3 -3
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/models.py +5 -5
  7. fin_infra/analytics/portfolio.py +18 -18
  8. fin_infra/analytics/projections.py +1 -3
  9. fin_infra/analytics/spending.py +4 -5
  10. fin_infra/banking/__init__.py +27 -28
  11. fin_infra/banking/history.py +12 -13
  12. fin_infra/banking/utils.py +27 -26
  13. fin_infra/brokerage/__init__.py +29 -31
  14. fin_infra/budgets/__init__.py +3 -3
  15. fin_infra/budgets/add.py +16 -17
  16. fin_infra/budgets/alerts.py +4 -4
  17. fin_infra/budgets/ease.py +1 -2
  18. fin_infra/budgets/models.py +1 -2
  19. fin_infra/budgets/templates.py +4 -4
  20. fin_infra/budgets/tracker.py +4 -4
  21. fin_infra/cashflows/__init__.py +3 -3
  22. fin_infra/cashflows/core.py +1 -1
  23. fin_infra/categorization/__init__.py +1 -1
  24. fin_infra/categorization/add.py +2 -3
  25. fin_infra/categorization/ease.py +3 -3
  26. fin_infra/categorization/engine.py +18 -15
  27. fin_infra/categorization/llm_layer.py +13 -10
  28. fin_infra/categorization/models.py +3 -4
  29. fin_infra/categorization/rules.py +2 -4
  30. fin_infra/categorization/taxonomy.py +2 -2
  31. fin_infra/chat/__init__.py +6 -6
  32. fin_infra/chat/planning.py +1 -2
  33. fin_infra/cli/cmds/scaffold_cmds.py +16 -17
  34. fin_infra/clients/__init__.py +23 -1
  35. fin_infra/clients/base.py +1 -1
  36. fin_infra/clients/plaid.py +2 -2
  37. fin_infra/compliance/__init__.py +5 -4
  38. fin_infra/credit/add.py +6 -7
  39. fin_infra/credit/experian/auth.py +2 -2
  40. fin_infra/credit/experian/client.py +1 -1
  41. fin_infra/credit/experian/parser.py +5 -5
  42. fin_infra/credit/experian/provider.py +4 -4
  43. fin_infra/crypto/__init__.py +9 -11
  44. fin_infra/crypto/insights.py +4 -3
  45. fin_infra/documents/add.py +6 -8
  46. fin_infra/documents/analysis.py +9 -9
  47. fin_infra/documents/ease.py +14 -14
  48. fin_infra/documents/models.py +5 -6
  49. fin_infra/documents/ocr.py +7 -7
  50. fin_infra/documents/storage.py +21 -13
  51. fin_infra/exceptions.py +0 -1
  52. fin_infra/goals/__init__.py +8 -8
  53. fin_infra/goals/add.py +36 -36
  54. fin_infra/goals/funding.py +4 -6
  55. fin_infra/goals/management.py +5 -6
  56. fin_infra/goals/milestones.py +7 -8
  57. fin_infra/goals/models.py +9 -13
  58. fin_infra/insights/__init__.py +6 -3
  59. fin_infra/insights/aggregator.py +1 -1
  60. fin_infra/investments/__init__.py +3 -3
  61. fin_infra/investments/add.py +23 -23
  62. fin_infra/investments/ease.py +2 -2
  63. fin_infra/investments/models.py +27 -29
  64. fin_infra/investments/providers/base.py +12 -13
  65. fin_infra/investments/providers/plaid.py +52 -26
  66. fin_infra/investments/providers/snaptrade.py +19 -19
  67. fin_infra/investments/scaffold_templates/README.md +17 -17
  68. fin_infra/markets/__init__.py +7 -5
  69. fin_infra/models/__init__.py +10 -10
  70. fin_infra/models/accounts.py +4 -5
  71. fin_infra/models/brokerage.py +2 -1
  72. fin_infra/models/candle.py +1 -0
  73. fin_infra/models/money.py +1 -0
  74. fin_infra/models/quotes.py +4 -3
  75. fin_infra/models/tax.py +2 -1
  76. fin_infra/models/transactions.py +4 -5
  77. fin_infra/net_worth/__init__.py +8 -1
  78. fin_infra/net_worth/aggregator.py +5 -3
  79. fin_infra/net_worth/calculator.py +1 -1
  80. fin_infra/net_worth/insights.py +7 -8
  81. fin_infra/normalization/__init__.py +4 -4
  82. fin_infra/normalization/currency_converter.py +7 -8
  83. fin_infra/normalization/models.py +9 -10
  84. fin_infra/normalization/providers/exchangerate.py +5 -5
  85. fin_infra/normalization/providers/static_mappings.py +1 -1
  86. fin_infra/normalization/symbol_resolver.py +3 -4
  87. fin_infra/obs/classifier.py +3 -3
  88. fin_infra/providers/banking/plaid_client.py +5 -5
  89. fin_infra/providers/banking/teller_client.py +7 -6
  90. fin_infra/providers/base.py +27 -2
  91. fin_infra/providers/brokerage/alpaca.py +4 -4
  92. fin_infra/providers/market/alphavantage.py +6 -11
  93. fin_infra/providers/market/ccxt_crypto.py +19 -3
  94. fin_infra/providers/market/coingecko.py +5 -6
  95. fin_infra/providers/market/yahoo.py +23 -8
  96. fin_infra/providers/tax/__init__.py +1 -1
  97. fin_infra/providers/tax/irs.py +1 -1
  98. fin_infra/providers/tax/mock.py +5 -5
  99. fin_infra/providers/tax/taxbit.py +1 -1
  100. fin_infra/recurring/__init__.py +6 -6
  101. fin_infra/recurring/add.py +6 -5
  102. fin_infra/recurring/detector.py +7 -7
  103. fin_infra/recurring/detectors_llm.py +10 -10
  104. fin_infra/recurring/ease.py +6 -8
  105. fin_infra/recurring/insights.py +25 -24
  106. fin_infra/recurring/normalizer.py +7 -7
  107. fin_infra/recurring/normalizers.py +31 -30
  108. fin_infra/recurring/summary.py +13 -15
  109. fin_infra/scaffold/budgets.py +9 -9
  110. fin_infra/scaffold/goals.py +9 -9
  111. fin_infra/security/__init__.py +8 -8
  112. fin_infra/security/add.py +1 -2
  113. fin_infra/security/audit.py +6 -7
  114. fin_infra/security/encryption.py +6 -6
  115. fin_infra/security/models.py +7 -7
  116. fin_infra/security/pii_filter.py +16 -16
  117. fin_infra/security/token_store.py +2 -3
  118. fin_infra/settings.py +2 -1
  119. fin_infra/tax/__init__.py +1 -1
  120. fin_infra/tax/add.py +5 -4
  121. fin_infra/tax/tlh.py +10 -10
  122. fin_infra/utils/__init__.py +15 -1
  123. fin_infra/utils/deprecation.py +161 -0
  124. fin_infra/utils/http.py +4 -3
  125. fin_infra/utils/retry.py +2 -1
  126. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
  127. fin_infra-0.4.0.dist-info/RECORD +181 -0
  128. fin_infra-0.1.69.dist-info/RECORD +0 -180
  129. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
  130. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
  131. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -7,7 +7,7 @@ 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
10
+ from typing import TYPE_CHECKING
11
11
 
12
12
  from fastapi import HTTPException
13
13
  from pydantic import BaseModel, Field
@@ -24,10 +24,10 @@ except ImportError:
24
24
 
25
25
  from .ease import easy_investments
26
26
  from .models import (
27
+ AssetAllocation,
27
28
  Holding,
28
- InvestmentTransaction,
29
29
  InvestmentAccount,
30
- AssetAllocation,
30
+ InvestmentTransaction,
31
31
  Security,
32
32
  )
33
33
  from .providers.base import InvestmentProvider
@@ -37,55 +37,55 @@ from .providers.base import InvestmentProvider
37
37
  class HoldingsRequest(BaseModel):
38
38
  """Request model for holdings endpoint."""
39
39
 
40
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
41
- user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
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")
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")
44
44
 
45
45
 
46
46
  class TransactionsRequest(BaseModel):
47
47
  """Request model for transactions endpoint."""
48
48
 
49
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
50
- user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
51
- user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
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)")
52
52
  start_date: date = Field(..., description="Start date for transactions (YYYY-MM-DD)")
53
53
  end_date: date = Field(..., description="End date for transactions (YYYY-MM-DD)")
54
- account_ids: Optional[list[str]] = Field(None, description="Filter by specific account IDs")
54
+ account_ids: list[str] | None = Field(None, description="Filter by specific account IDs")
55
55
 
56
56
 
57
57
  class AccountsRequest(BaseModel):
58
58
  """Request model for investment accounts endpoint."""
59
59
 
60
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
61
- user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
62
- user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
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)")
63
63
 
64
64
 
65
65
  class AllocationRequest(BaseModel):
66
66
  """Request model for asset allocation endpoint."""
67
67
 
68
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
69
- user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
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")
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")
72
72
 
73
73
 
74
74
  class SecuritiesRequest(BaseModel):
75
75
  """Request model for securities endpoint."""
76
76
 
77
- access_token: Optional[str] = Field(None, description="Plaid access token (Plaid only)")
78
- user_id: Optional[str] = Field(None, description="SnapTrade user ID (SnapTrade only)")
79
- user_secret: Optional[str] = Field(None, description="SnapTrade user secret (SnapTrade only)")
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)")
80
80
  security_ids: list[str] = Field(..., description="List of security IDs to retrieve")
81
81
 
82
82
 
83
83
  def add_investments(
84
84
  app: FastAPI,
85
85
  prefix: str = "/investments",
86
- provider: Optional[InvestmentProvider] = None,
86
+ provider: InvestmentProvider | None = None,
87
87
  include_in_schema: bool = True,
88
- tags: Optional[list[str]] = None,
88
+ tags: list[str] | None = None,
89
89
  ) -> InvestmentProvider:
90
90
  """Add investment endpoints to FastAPI application.
91
91
 
@@ -8,13 +8,13 @@ and SnapTrade (retail brokerages) for maximum coverage.
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
- from typing import Any, Literal, Optional
11
+ from typing import Any, Literal
12
12
 
13
13
  from .providers.base import InvestmentProvider
14
14
 
15
15
 
16
16
  def easy_investments(
17
- provider: Optional[Literal["plaid", "snaptrade"]] = None,
17
+ provider: Literal["plaid", "snaptrade"] | None = None,
18
18
  **config: Any,
19
19
  ) -> InvestmentProvider:
20
20
  """Create investment provider with auto-configuration.
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
  from datetime import date
21
21
  from decimal import Decimal
22
22
  from enum import Enum
23
- from typing import TYPE_CHECKING, Dict, List, Optional
23
+ from typing import TYPE_CHECKING
24
24
 
25
25
  from pydantic import BaseModel, ConfigDict, Field, computed_field
26
26
 
@@ -121,22 +121,20 @@ class Security(BaseModel):
121
121
 
122
122
  # Identifiers (at least one required for matching)
123
123
  security_id: str = Field(..., description="Provider-specific security ID")
124
- cusip: Optional[str] = Field(None, description="CUSIP identifier (US securities)")
125
- isin: Optional[str] = Field(None, description="ISIN identifier (international)")
126
- sedol: Optional[str] = Field(None, description="SEDOL identifier (UK securities)")
127
- ticker_symbol: Optional[str] = Field(None, description="Trading symbol (AAPL, GOOGL)")
124
+ cusip: str | None = Field(None, description="CUSIP identifier (US securities)")
125
+ isin: str | None = Field(None, description="ISIN identifier (international)")
126
+ sedol: str | None = Field(None, description="SEDOL identifier (UK securities)")
127
+ ticker_symbol: str | None = Field(None, description="Trading symbol (AAPL, GOOGL)")
128
128
 
129
129
  # Basic info
130
130
  name: str = Field(..., description="Security name")
131
131
  type: SecurityType = Field(..., description="Security type (equity, etf, bond, etc.)")
132
- sector: Optional[str] = Field(
133
- None, description="Sector classification (Technology, Healthcare)"
134
- )
132
+ sector: str | None = Field(None, description="Sector classification (Technology, Healthcare)")
135
133
 
136
134
  # Market data
137
- close_price: Optional[Decimal] = Field(None, ge=0, description="Latest closing price")
138
- close_price_as_of: Optional[date] = Field(None, description="Date of close_price")
139
- exchange: Optional[str] = Field(None, description="Exchange (NASDAQ, NYSE, etc.)")
135
+ close_price: Decimal | None = Field(None, ge=0, description="Latest closing price")
136
+ close_price_as_of: date | None = Field(None, description="Date of close_price")
137
+ exchange: str | None = Field(None, description="Exchange (NASDAQ, NYSE, etc.)")
140
138
  currency: str = Field("USD", description="Currency code (USD, EUR, etc.)")
141
139
 
142
140
 
@@ -198,26 +196,26 @@ class Holding(BaseModel):
198
196
  institution_value: Decimal = Field(
199
197
  ..., ge=0, description="Current market value (quantity × price)"
200
198
  )
201
- cost_basis: Optional[Decimal] = Field(
199
+ cost_basis: Decimal | None = Field(
202
200
  None, ge=0, description="Total cost basis (original purchase price)"
203
201
  )
204
202
 
205
203
  # Additional data
206
204
  currency: str = Field("USD", description="Currency code")
207
- unofficial_currency_code: Optional[str] = Field(None, description="For crypto/alt currencies")
208
- as_of_date: Optional[date] = Field(None, description="Date of pricing data")
205
+ unofficial_currency_code: str | None = Field(None, description="For crypto/alt currencies")
206
+ as_of_date: date | None = Field(None, description="Date of pricing data")
209
207
 
210
208
  if TYPE_CHECKING:
211
209
 
212
210
  @property
213
- def unrealized_gain_loss(self) -> Optional[Decimal]:
211
+ def unrealized_gain_loss(self) -> Decimal | None:
214
212
  """Calculate unrealized gain/loss (current value - cost basis)."""
215
213
  if self.cost_basis is None:
216
214
  return None
217
215
  return self.institution_value - self.cost_basis
218
216
 
219
217
  @property
220
- def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
218
+ def unrealized_gain_loss_percent(self) -> Decimal | None:
221
219
  """Calculate unrealized gain/loss percentage."""
222
220
  if self.cost_basis is None or self.cost_basis == 0:
223
221
  return None
@@ -228,7 +226,7 @@ class Holding(BaseModel):
228
226
 
229
227
  @computed_field
230
228
  @property
231
- def unrealized_gain_loss(self) -> Optional[Decimal]:
229
+ def unrealized_gain_loss(self) -> Decimal | None:
232
230
  """Calculate unrealized gain/loss (current value - cost basis)."""
233
231
  if self.cost_basis is None:
234
232
  return None
@@ -236,7 +234,7 @@ class Holding(BaseModel):
236
234
 
237
235
  @computed_field
238
236
  @property
239
- def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
237
+ def unrealized_gain_loss_percent(self) -> Decimal | None:
240
238
  """Calculate unrealized gain/loss percentage."""
241
239
  if self.cost_basis is None or self.cost_basis == 0:
242
240
  return None
@@ -304,17 +302,17 @@ class InvestmentTransaction(BaseModel):
304
302
  transaction_type: TransactionType = Field(
305
303
  ..., alias="type", description="Transaction type (buy, sell, dividend)"
306
304
  )
307
- subtype: Optional[str] = Field(None, description="Provider-specific subtype")
305
+ subtype: str | None = Field(None, description="Provider-specific subtype")
308
306
 
309
307
  # Amounts
310
308
  quantity: Decimal = Field(..., description="Number of shares (0 for fees/dividends)")
311
309
  amount: Decimal = Field(..., description="Transaction amount (negative for purchases)")
312
- price: Optional[Decimal] = Field(None, ge=0, description="Price per share")
313
- fees: Optional[Decimal] = Field(None, ge=0, description="Transaction fees")
310
+ price: Decimal | None = Field(None, ge=0, description="Price per share")
311
+ fees: Decimal | None = Field(None, ge=0, description="Transaction fees")
314
312
 
315
313
  # Additional data
316
314
  currency: str = Field("USD", description="Currency code")
317
- unofficial_currency_code: Optional[str] = Field(None, description="For crypto/alt currencies")
315
+ unofficial_currency_code: str | None = Field(None, description="For crypto/alt currencies")
318
316
 
319
317
 
320
318
  class InvestmentAccount(BaseModel):
@@ -371,15 +369,15 @@ class InvestmentAccount(BaseModel):
371
369
  account_id: str = Field(..., description="Account identifier")
372
370
  name: str = Field(..., description="Account name (Fidelity 401k)")
373
371
  type: str = Field(..., description="Account type (investment)")
374
- subtype: Optional[str] = Field(None, description="Account subtype (401k, ira, brokerage)")
372
+ subtype: str | None = Field(None, description="Account subtype (401k, ira, brokerage)")
375
373
 
376
374
  # Balances
377
- balances: Dict[str, Optional[Decimal]] = Field(
375
+ balances: dict[str, Decimal | None] = Field(
378
376
  ..., description="Current, available, and limit balances"
379
377
  )
380
378
 
381
379
  # Holdings
382
- holdings: List[Holding] = Field(default_factory=list, description="List of holdings in account")
380
+ holdings: list[Holding] = Field(default_factory=list, description="List of holdings in account")
383
381
 
384
382
  if TYPE_CHECKING:
385
383
 
@@ -405,7 +403,7 @@ class InvestmentAccount(BaseModel):
405
403
  return holdings_value - self.total_cost_basis
406
404
 
407
405
  @property
408
- def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
406
+ def total_unrealized_gain_loss_percent(self) -> Decimal | None:
409
407
  """Calculate total unrealized P&L percentage."""
410
408
  if self.total_cost_basis == 0:
411
409
  return None
@@ -439,7 +437,7 @@ class InvestmentAccount(BaseModel):
439
437
 
440
438
  @computed_field
441
439
  @property
442
- def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
440
+ def total_unrealized_gain_loss_percent(self) -> Decimal | None:
443
441
  """Calculate total unrealized P&L percentage."""
444
442
  if self.total_cost_basis == 0:
445
443
  return None
@@ -487,11 +485,11 @@ class AssetAllocation(BaseModel):
487
485
  },
488
486
  )
489
487
 
490
- by_security_type: Dict[SecurityType, float] = Field(
488
+ by_security_type: dict[SecurityType, float] = Field(
491
489
  default_factory=dict,
492
490
  description="Percentage breakdown by security type (equity, bond, etc.)",
493
491
  )
494
- by_sector: Dict[str, float] = Field(
492
+ by_sector: dict[str, float] = Field(
495
493
  default_factory=dict,
496
494
  description="Percentage breakdown by sector (Technology, Healthcare, etc.)",
497
495
  )
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
6
  from datetime import date
7
- from typing import List, Optional
8
7
 
9
8
  # Import will work once models.py is fully implemented in Task 3
10
9
  # For now, using TYPE_CHECKING to avoid circular imports
@@ -30,8 +29,8 @@ class InvestmentProvider(ABC):
30
29
 
31
30
  @abstractmethod
32
31
  async def get_holdings(
33
- self, access_token: str, account_ids: Optional[List[str]] = None
34
- ) -> List[Holding]:
32
+ self, access_token: str, account_ids: list[str] | None = None
33
+ ) -> list[Holding]:
35
34
  """Fetch holdings for investment accounts.
36
35
 
37
36
  Args:
@@ -54,8 +53,8 @@ class InvestmentProvider(ABC):
54
53
  access_token: str,
55
54
  start_date: date,
56
55
  end_date: date,
57
- account_ids: Optional[List[str]] = None,
58
- ) -> List[InvestmentTransaction]:
56
+ account_ids: list[str] | None = None,
57
+ ) -> list[InvestmentTransaction]:
59
58
  """Fetch investment transactions within date range.
60
59
 
61
60
  Args:
@@ -77,7 +76,7 @@ class InvestmentProvider(ABC):
77
76
  pass
78
77
 
79
78
  @abstractmethod
80
- async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
79
+ async def get_securities(self, access_token: str, security_ids: list[str]) -> list[Security]:
81
80
  """Fetch security details (ticker, name, type, current price).
82
81
 
83
82
  Args:
@@ -95,7 +94,7 @@ class InvestmentProvider(ABC):
95
94
  pass
96
95
 
97
96
  @abstractmethod
98
- async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
97
+ async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
99
98
  """Fetch investment accounts with aggregated holdings.
100
99
 
101
100
  Args:
@@ -113,7 +112,7 @@ class InvestmentProvider(ABC):
113
112
 
114
113
  # Helper methods (concrete - shared across all providers)
115
114
 
116
- def calculate_allocation(self, holdings: List[Holding]) -> AssetAllocation:
115
+ def calculate_allocation(self, holdings: list[Holding]) -> AssetAllocation:
117
116
  """Calculate asset allocation by security type and sector.
118
117
 
119
118
  Groups holdings by security type (equity, bond, ETF, etc.) and calculates
@@ -183,7 +182,7 @@ class InvestmentProvider(ABC):
183
182
  cash_percent=cash_percent,
184
183
  )
185
184
 
186
- def calculate_portfolio_metrics(self, holdings: List[Holding]) -> dict:
185
+ def calculate_portfolio_metrics(self, holdings: list[Holding]) -> dict:
187
186
  """Calculate total value, cost basis, unrealized gain/loss.
188
187
 
189
188
  Aggregates holdings to calculate portfolio-level metrics.
@@ -237,10 +236,10 @@ class InvestmentProvider(ABC):
237
236
  Standardized SecurityType enum value
238
237
 
239
238
  Example mappings:
240
- Plaid: "equity" SecurityType.equity
241
- Plaid: "mutual fund" SecurityType.mutual_fund
242
- SnapTrade: "cs" SecurityType.equity (common stock)
243
- SnapTrade: "etf" SecurityType.etf
239
+ Plaid: "equity" -> SecurityType.equity
240
+ Plaid: "mutual fund" -> SecurityType.mutual_fund
241
+ SnapTrade: "cs" -> SecurityType.equity (common stock)
242
+ SnapTrade: "etf" -> SecurityType.etf
244
243
 
245
244
  Note:
246
245
  Override in provider-specific implementations for custom mappings.
@@ -10,21 +10,35 @@ from __future__ import annotations
10
10
 
11
11
  from datetime import date
12
12
  from decimal import Decimal
13
- from typing import Any, Dict, List, Optional, cast
14
-
15
- from plaid.api import plaid_api
16
- from plaid.model.investments_holdings_get_request import InvestmentsHoldingsGetRequest
17
- from plaid.model.investments_transactions_get_request import (
18
- InvestmentsTransactionsGetRequest,
19
- )
20
- from plaid.model.investments_holdings_get_response import InvestmentsHoldingsGetResponse
21
- from plaid.model.investments_transactions_get_response import (
22
- InvestmentsTransactionsGetResponse,
23
- )
24
- from plaid.exceptions import ApiException
25
- import plaid
26
- from plaid.api_client import ApiClient
27
- from plaid.configuration import Configuration
13
+ from typing import Any, cast
14
+
15
+ try:
16
+ import plaid
17
+ from plaid.api import plaid_api
18
+ from plaid.api_client import ApiClient
19
+ from plaid.configuration import Configuration
20
+ from plaid.exceptions import ApiException
21
+ from plaid.model.investments_holdings_get_request import InvestmentsHoldingsGetRequest
22
+ from plaid.model.investments_holdings_get_response import InvestmentsHoldingsGetResponse
23
+ from plaid.model.investments_transactions_get_request import (
24
+ InvestmentsTransactionsGetRequest,
25
+ )
26
+ from plaid.model.investments_transactions_get_response import (
27
+ InvestmentsTransactionsGetResponse,
28
+ )
29
+
30
+ HAS_PLAID = True
31
+ except ImportError: # pragma: no cover
32
+ HAS_PLAID = False
33
+ plaid_api = None
34
+ plaid = None
35
+ ApiClient = None
36
+ Configuration = None
37
+ ApiException = Exception
38
+ InvestmentsHoldingsGetRequest = Any
39
+ InvestmentsTransactionsGetRequest = Any
40
+ InvestmentsHoldingsGetResponse = Any
41
+ InvestmentsTransactionsGetResponse = Any
28
42
 
29
43
  from ..models import (
30
44
  Holding,
@@ -36,6 +50,15 @@ from ..models import (
36
50
  from .base import InvestmentProvider
37
51
 
38
52
 
53
+ def _require_plaid() -> None:
54
+ """Raise ImportError if plaid-python is not installed."""
55
+ if not HAS_PLAID:
56
+ raise ImportError(
57
+ "Plaid support requires the 'plaid-python' package. "
58
+ "Install with: pip install fin-infra[plaid] or pip install fin-infra[banking]"
59
+ )
60
+
61
+
39
62
  class PlaidInvestmentProvider(InvestmentProvider):
40
63
  """Plaid Investment API provider.
41
64
 
@@ -76,7 +99,10 @@ class PlaidInvestmentProvider(InvestmentProvider):
76
99
 
77
100
  Raises:
78
101
  ValueError: If client_id or secret is missing
102
+ ImportError: If plaid-python is not installed
79
103
  """
104
+ _require_plaid()
105
+
80
106
  if not client_id or not secret:
81
107
  raise ValueError("client_id and secret are required for Plaid provider")
82
108
 
@@ -102,11 +128,11 @@ class PlaidInvestmentProvider(InvestmentProvider):
102
128
  "development": plaid.Environment.Sandbox, # Map development to sandbox
103
129
  "production": plaid.Environment.Production,
104
130
  }
105
- return cast(str, hosts.get(environment.lower(), plaid.Environment.Sandbox))
131
+ return cast("str", hosts.get(environment.lower(), plaid.Environment.Sandbox))
106
132
 
107
133
  async def get_holdings(
108
- self, access_token: str, account_ids: Optional[List[str]] = None
109
- ) -> List[Holding]:
134
+ self, access_token: str, account_ids: list[str] | None = None
135
+ ) -> list[Holding]:
110
136
  """Fetch investment holdings from Plaid.
111
137
 
112
138
  Retrieves holdings with security details, quantity, cost basis, and current value.
@@ -163,8 +189,8 @@ class PlaidInvestmentProvider(InvestmentProvider):
163
189
  access_token: str,
164
190
  start_date: date,
165
191
  end_date: date,
166
- account_ids: Optional[List[str]] = None,
167
- ) -> List[InvestmentTransaction]:
192
+ account_ids: list[str] | None = None,
193
+ ) -> list[InvestmentTransaction]:
168
194
  """Fetch investment transactions from Plaid.
169
195
 
170
196
  Retrieves buy/sell/dividend transactions within the specified date range.
@@ -226,7 +252,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
226
252
  except ApiException as e:
227
253
  raise self._transform_error(e)
228
254
 
229
- async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
255
+ async def get_securities(self, access_token: str, security_ids: list[str]) -> list[Security]:
230
256
  """Fetch security details from Plaid holdings.
231
257
 
232
258
  Note: Plaid doesn't have a dedicated securities endpoint.
@@ -264,7 +290,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
264
290
  except ApiException as e:
265
291
  raise self._transform_error(e)
266
292
 
267
- async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
293
+ async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
268
294
  """Fetch investment accounts with aggregated holdings.
269
295
 
270
296
  Returns accounts with total value, cost basis, and unrealized P&L.
@@ -295,7 +321,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
295
321
  }
296
322
 
297
323
  # Group holdings by account
298
- accounts_map: Dict[str, Dict[str, Any]] = {}
324
+ accounts_map: dict[str, dict[str, Any]] = {}
299
325
  for plaid_holding in response.holdings:
300
326
  holding_dict = plaid_holding.to_dict()
301
327
  account_id = holding_dict["account_id"]
@@ -347,7 +373,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
347
373
 
348
374
  # Helper methods for data transformation
349
375
 
350
- def _transform_security(self, plaid_security: Dict[str, Any]) -> Security:
376
+ def _transform_security(self, plaid_security: dict[str, Any]) -> Security:
351
377
  """Transform Plaid security data to Security model."""
352
378
  # Handle close_price - Plaid may return None for securities without recent pricing
353
379
  close_price_raw = plaid_security.get("close_price")
@@ -368,7 +394,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
368
394
  currency=plaid_security.get("iso_currency_code", "USD"),
369
395
  )
370
396
 
371
- def _transform_holding(self, plaid_holding: Dict[str, Any], security: Security) -> Holding:
397
+ def _transform_holding(self, plaid_holding: dict[str, Any], security: Security) -> Holding:
372
398
  """Transform Plaid holding data to Holding model."""
373
399
  return Holding(
374
400
  account_id=plaid_holding["account_id"],
@@ -384,7 +410,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
384
410
  )
385
411
 
386
412
  def _transform_transaction(
387
- self, plaid_transaction: Dict[str, Any], security: Security
413
+ self, plaid_transaction: dict[str, Any], security: Security
388
414
  ) -> InvestmentTransaction:
389
415
  """Transform Plaid investment transaction to InvestmentTransaction model."""
390
416
  # Map Plaid transaction type to our enum
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from datetime import date
13
13
  from decimal import Decimal
14
- from typing import Any, Dict, List, Optional, cast
14
+ from typing import Any, cast
15
15
 
16
16
  import httpx
17
17
 
@@ -93,7 +93,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
93
93
  timeout=30.0,
94
94
  )
95
95
 
96
- def _auth_headers(self, user_id: str, user_secret: str) -> Dict[str, str]:
96
+ def _auth_headers(self, user_id: str, user_secret: str) -> dict[str, str]:
97
97
  """Build authentication headers for SnapTrade API requests.
98
98
 
99
99
  SECURITY: User secrets are passed in headers, NOT URL params.
@@ -115,8 +115,8 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
115
115
  async def get_holdings(
116
116
  self,
117
117
  access_token: str,
118
- account_ids: Optional[List[str]] = None,
119
- ) -> List[Holding]:
118
+ account_ids: list[str] | None = None,
119
+ ) -> list[Holding]:
120
120
  """Fetch investment holdings from SnapTrade.
121
121
 
122
122
  Note: SnapTrade uses user_id + user_secret, passed as access_token in format "user_id:user_secret"
@@ -171,15 +171,15 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
171
171
  except httpx.HTTPStatusError as e:
172
172
  raise self._transform_error(e)
173
173
  except Exception as e:
174
- raise ValueError(f"SnapTrade API error: {str(e)}")
174
+ raise ValueError(f"SnapTrade API error: {e!s}")
175
175
 
176
176
  async def get_transactions(
177
177
  self,
178
178
  access_token: str,
179
179
  start_date: date,
180
180
  end_date: date,
181
- account_ids: Optional[List[str]] = None,
182
- ) -> List[InvestmentTransaction]:
181
+ account_ids: list[str] | None = None,
182
+ ) -> list[InvestmentTransaction]:
183
183
  """Fetch investment transactions from SnapTrade.
184
184
 
185
185
  Args:
@@ -244,9 +244,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
244
244
  except httpx.HTTPStatusError as e:
245
245
  raise self._transform_error(e)
246
246
  except Exception as e:
247
- raise ValueError(f"SnapTrade API error: {str(e)}")
247
+ raise ValueError(f"SnapTrade API error: {e!s}")
248
248
 
249
- async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
249
+ async def get_securities(self, access_token: str, security_ids: list[str]) -> list[Security]:
250
250
  """Fetch security details from SnapTrade positions.
251
251
 
252
252
  Note: SnapTrade doesn't have a dedicated securities endpoint.
@@ -267,7 +267,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
267
267
  >>> for security in securities:
268
268
  ... print(f"{security.ticker_symbol}: ${security.close_price}")
269
269
  """
270
- user_id, user_secret = self._parse_access_token(access_token)
270
+ _user_id, _user_secret = self._parse_access_token(access_token)
271
271
 
272
272
  try:
273
273
  # Get all holdings to extract securities
@@ -282,9 +282,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
282
282
  return list(securities_map.values())
283
283
 
284
284
  except Exception as e:
285
- raise ValueError(f"SnapTrade API error: {str(e)}")
285
+ raise ValueError(f"SnapTrade API error: {e!s}")
286
286
 
287
- async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
287
+ async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
288
288
  """Fetch investment accounts with aggregated holdings.
289
289
 
290
290
  Returns accounts with total value, cost basis, and unrealized P&L.
@@ -356,9 +356,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
356
356
  except httpx.HTTPStatusError as e:
357
357
  raise self._transform_error(e)
358
358
  except Exception as e:
359
- raise ValueError(f"SnapTrade API error: {str(e)}")
359
+ raise ValueError(f"SnapTrade API error: {e!s}")
360
360
 
361
- async def list_connections(self, access_token: str) -> List[Dict[str, Any]]:
361
+ async def list_connections(self, access_token: str) -> list[dict[str, Any]]:
362
362
  """List brokerage connections for a user.
363
363
 
364
364
  Returns which brokerages the user has connected (E*TRADE, Robinhood, etc.).
@@ -381,14 +381,14 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
381
381
  url = f"{self.base_url}/connections"
382
382
  response = await self.client.get(url, headers=auth_headers)
383
383
  response.raise_for_status()
384
- return cast(list[dict[str, Any]], await response.json())
384
+ return cast("list[dict[str, Any]]", await response.json())
385
385
 
386
386
  except httpx.HTTPStatusError as e:
387
387
  raise self._transform_error(e)
388
388
  except Exception as e:
389
- raise ValueError(f"SnapTrade API error: {str(e)}")
389
+ raise ValueError(f"SnapTrade API error: {e!s}")
390
390
 
391
- def get_brokerage_capabilities(self, brokerage_name: str) -> Dict[str, Any]:
391
+ def get_brokerage_capabilities(self, brokerage_name: str) -> dict[str, Any]:
392
392
  """Get capabilities for a specific brokerage.
393
393
 
394
394
  Important: Robinhood is READ-ONLY (no trading support).
@@ -480,7 +480,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
480
480
  except ValueError:
481
481
  raise ValueError("Invalid access_token format. Expected 'user_id:user_secret'")
482
482
 
483
- def _transform_holding(self, snaptrade_position: Dict[str, Any], account_id: str) -> Holding:
483
+ def _transform_holding(self, snaptrade_position: dict[str, Any], account_id: str) -> Holding:
484
484
  """Transform SnapTrade position data to Holding model."""
485
485
  symbol_data = snaptrade_position.get("symbol", {})
486
486
 
@@ -510,7 +510,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
510
510
  )
511
511
 
512
512
  def _transform_transaction(
513
- self, snaptrade_tx: Dict[str, Any], account_id: str
513
+ self, snaptrade_tx: dict[str, Any], account_id: str
514
514
  ) -> InvestmentTransaction:
515
515
  """Transform SnapTrade transaction to InvestmentTransaction model."""
516
516
  symbol_data = snaptrade_tx.get("symbol", {})