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
@@ -17,17 +17,17 @@ Models are provider-agnostic and normalize data from Plaid, SnapTrade, etc.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- from datetime import date, datetime
20
+ from datetime import date
21
21
  from decimal import Decimal
22
22
  from enum import Enum
23
- from typing import Dict, List, Optional
23
+ from typing import TYPE_CHECKING, Optional
24
24
 
25
25
  from pydantic import BaseModel, ConfigDict, Field, computed_field
26
26
 
27
27
 
28
28
  class SecurityType(str, Enum):
29
29
  """Security type classification.
30
-
30
+
31
31
  Categories:
32
32
  - equity: Common stock (AAPL, GOOGL, etc.)
33
33
  - etf: Exchange-traded fund (SPY, QQQ, etc.)
@@ -49,7 +49,7 @@ class SecurityType(str, Enum):
49
49
 
50
50
  class TransactionType(str, Enum):
51
51
  """Investment transaction type.
52
-
52
+
53
53
  Categories:
54
54
  - buy: Purchase of security
55
55
  - sell: Sale of security
@@ -71,7 +71,7 @@ class TransactionType(str, Enum):
71
71
  fee = "fee"
72
72
  tax = "tax"
73
73
  transfer = "transfer"
74
- split = "split"
74
+ split = "split" # type: ignore[assignment] # str.split() name conflict
75
75
  merger = "merger"
76
76
  cancel = "cancel"
77
77
  other = "other"
@@ -79,10 +79,10 @@ class TransactionType(str, Enum):
79
79
 
80
80
  class Security(BaseModel):
81
81
  """Security details (stock, bond, ETF, etc.).
82
-
82
+
83
83
  Represents a tradable security with identifying information and current market data.
84
84
  Normalized across providers (Plaid, SnapTrade).
85
-
85
+
86
86
  Example:
87
87
  >>> security = Security(
88
88
  ... security_id="plaid_sec_123",
@@ -129,7 +129,9 @@ class Security(BaseModel):
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(None, description="Sector classification (Technology, Healthcare)")
132
+ sector: Optional[str] = Field(
133
+ None, description="Sector classification (Technology, Healthcare)"
134
+ )
133
135
 
134
136
  # Market data
135
137
  close_price: Optional[Decimal] = Field(None, ge=0, description="Latest closing price")
@@ -140,10 +142,10 @@ class Security(BaseModel):
140
142
 
141
143
  class Holding(BaseModel):
142
144
  """Investment holding with current value and cost basis.
143
-
145
+
144
146
  Represents a position in a specific security within an investment account.
145
147
  Includes quantity, current value, cost basis, and calculated P&L.
146
-
148
+
147
149
  Example:
148
150
  >>> holding = Holding(
149
151
  ... account_id="acct_123",
@@ -193,38 +195,61 @@ class Holding(BaseModel):
193
195
  # Position data
194
196
  quantity: Decimal = Field(..., ge=0, description="Number of shares/units held")
195
197
  institution_price: Decimal = Field(..., ge=0, description="Current price per share")
196
- institution_value: Decimal = Field(..., ge=0, description="Current market value (quantity × price)")
197
- cost_basis: Optional[Decimal] = Field(None, ge=0, description="Total cost basis (original purchase price)")
198
+ institution_value: Decimal = Field(
199
+ ..., ge=0, description="Current market value (quantity × price)"
200
+ )
201
+ cost_basis: Optional[Decimal] = Field(
202
+ None, ge=0, description="Total cost basis (original purchase price)"
203
+ )
198
204
 
199
205
  # Additional data
200
206
  currency: str = Field("USD", description="Currency code")
201
207
  unofficial_currency_code: Optional[str] = Field(None, description="For crypto/alt currencies")
202
208
  as_of_date: Optional[date] = Field(None, description="Date of pricing data")
203
209
 
204
- @computed_field
205
- @property
206
- def unrealized_gain_loss(self) -> Optional[Decimal]:
207
- """Calculate unrealized gain/loss (current value - cost basis)."""
208
- if self.cost_basis is None:
209
- return None
210
- return self.institution_value - self.cost_basis
211
-
212
- @computed_field
213
- @property
214
- def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
215
- """Calculate unrealized gain/loss percentage."""
216
- if self.cost_basis is None or self.cost_basis == 0:
217
- return None
218
- gain_loss = self.institution_value - self.cost_basis
219
- return round((gain_loss / self.cost_basis) * 100, 2)
210
+ if TYPE_CHECKING:
211
+
212
+ @property
213
+ def unrealized_gain_loss(self) -> Optional[Decimal]:
214
+ """Calculate unrealized gain/loss (current value - cost basis)."""
215
+ if self.cost_basis is None:
216
+ return None
217
+ return self.institution_value - self.cost_basis
218
+
219
+ @property
220
+ def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
221
+ """Calculate unrealized gain/loss percentage."""
222
+ if self.cost_basis is None or self.cost_basis == 0:
223
+ return None
224
+ gain_loss = self.institution_value - self.cost_basis
225
+ return round((gain_loss / self.cost_basis) * 100, 2)
226
+
227
+ else:
228
+
229
+ @computed_field
230
+ @property
231
+ def unrealized_gain_loss(self) -> Optional[Decimal]:
232
+ """Calculate unrealized gain/loss (current value - cost basis)."""
233
+ if self.cost_basis is None:
234
+ return None
235
+ return self.institution_value - self.cost_basis
236
+
237
+ @computed_field
238
+ @property
239
+ def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
240
+ """Calculate unrealized gain/loss percentage."""
241
+ if self.cost_basis is None or self.cost_basis == 0:
242
+ return None
243
+ gain_loss = self.institution_value - self.cost_basis
244
+ return round((gain_loss / self.cost_basis) * 100, 2)
220
245
 
221
246
 
222
247
  class InvestmentTransaction(BaseModel):
223
248
  """Investment transaction (buy, sell, dividend, etc.).
224
-
249
+
225
250
  Represents a single transaction in an investment account.
226
251
  Used to calculate realized gains and track transaction history.
227
-
252
+
228
253
  Example:
229
254
  >>> transaction = InvestmentTransaction(
230
255
  ... transaction_id="tx_123",
@@ -276,7 +301,9 @@ class InvestmentTransaction(BaseModel):
276
301
  # Transaction details
277
302
  transaction_date: date = Field(..., alias="date", description="Transaction date")
278
303
  name: str = Field(..., description="Transaction description")
279
- transaction_type: TransactionType = Field(..., alias="type", description="Transaction type (buy, sell, dividend)")
304
+ transaction_type: TransactionType = Field(
305
+ ..., alias="type", description="Transaction type (buy, sell, dividend)"
306
+ )
280
307
  subtype: Optional[str] = Field(None, description="Provider-specific subtype")
281
308
 
282
309
  # Amounts
@@ -292,10 +319,10 @@ class InvestmentTransaction(BaseModel):
292
319
 
293
320
  class InvestmentAccount(BaseModel):
294
321
  """Investment account with aggregated holdings and metrics.
295
-
322
+
296
323
  Represents a complete investment account with all holdings, balances, and P&L.
297
324
  Includes calculated fields for total value, cost basis, and unrealized gains.
298
-
325
+
299
326
  Example:
300
327
  >>> account = InvestmentAccount(
301
328
  ... account_id="acct_123",
@@ -321,7 +348,11 @@ class InvestmentAccount(BaseModel):
321
348
  "holdings": [
322
349
  {
323
350
  "account_id": "acct_abc123",
324
- "security": {"ticker_symbol": "AAPL", "name": "Apple Inc.", "type": "equity"},
351
+ "security": {
352
+ "ticker_symbol": "AAPL",
353
+ "name": "Apple Inc.",
354
+ "type": "equity",
355
+ },
325
356
  "quantity": 10.5,
326
357
  "institution_price": 150.25,
327
358
  "institution_value": 1577.63,
@@ -343,49 +374,84 @@ class InvestmentAccount(BaseModel):
343
374
  subtype: Optional[str] = Field(None, description="Account subtype (401k, ira, brokerage)")
344
375
 
345
376
  # Balances
346
- balances: Dict[str, Optional[Decimal]] = Field(
377
+ balances: dict[str, Optional[Decimal]] = Field(
347
378
  ..., description="Current, available, and limit balances"
348
379
  )
349
380
 
350
381
  # Holdings
351
- holdings: List[Holding] = Field(default_factory=list, description="List of holdings in account")
352
-
353
- @computed_field
354
- @property
355
- def total_value(self) -> Decimal:
356
- """Calculate total account value (sum of holdings + cash)."""
357
- holdings_value = sum(h.institution_value for h in self.holdings)
358
- cash_balance = self.balances.get("current") or Decimal(0)
359
- return holdings_value + cash_balance
360
-
361
- @computed_field
362
- @property
363
- def total_cost_basis(self) -> Decimal:
364
- """Calculate total cost basis (sum of cost_basis across holdings)."""
365
- return sum(h.cost_basis for h in self.holdings if h.cost_basis is not None)
366
-
367
- @computed_field
368
- @property
369
- def total_unrealized_gain_loss(self) -> Decimal:
370
- """Calculate total unrealized P&L (value - cost_basis)."""
371
- holdings_value = sum(h.institution_value for h in self.holdings)
372
- return holdings_value - self.total_cost_basis
373
-
374
- @computed_field
375
- @property
376
- def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
377
- """Calculate total unrealized P&L percentage."""
378
- if self.total_cost_basis == 0:
379
- return None
380
- return round((self.total_unrealized_gain_loss / self.total_cost_basis) * 100, 2)
382
+ holdings: list[Holding] = Field(default_factory=list, description="List of holdings in account")
383
+
384
+ if TYPE_CHECKING:
385
+
386
+ @property
387
+ def total_value(self) -> Decimal:
388
+ """Calculate total account value (sum of holdings + cash)."""
389
+ holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
390
+ cash_balance = self.balances.get("current") or Decimal(0)
391
+ return holdings_value + cash_balance
392
+
393
+ @property
394
+ def total_cost_basis(self) -> Decimal:
395
+ """Calculate total cost basis (sum of cost_basis across holdings)."""
396
+ return sum(
397
+ (h.cost_basis for h in self.holdings if h.cost_basis is not None),
398
+ start=Decimal(0),
399
+ )
400
+
401
+ @property
402
+ def total_unrealized_gain_loss(self) -> Decimal:
403
+ """Calculate total unrealized P&L (value - cost_basis)."""
404
+ holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
405
+ return holdings_value - self.total_cost_basis
406
+
407
+ @property
408
+ def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
409
+ """Calculate total unrealized P&L percentage."""
410
+ if self.total_cost_basis == 0:
411
+ return None
412
+ return round((self.total_unrealized_gain_loss / self.total_cost_basis) * 100, 2)
413
+
414
+ else:
415
+
416
+ @computed_field
417
+ @property
418
+ def total_value(self) -> Decimal:
419
+ """Calculate total account value (sum of holdings + cash)."""
420
+ holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
421
+ cash_balance = self.balances.get("current") or Decimal(0)
422
+ return holdings_value + cash_balance
423
+
424
+ @computed_field
425
+ @property
426
+ def total_cost_basis(self) -> Decimal:
427
+ """Calculate total cost basis (sum of cost_basis across holdings)."""
428
+ return sum(
429
+ (h.cost_basis for h in self.holdings if h.cost_basis is not None),
430
+ start=Decimal(0),
431
+ )
432
+
433
+ @computed_field
434
+ @property
435
+ def total_unrealized_gain_loss(self) -> Decimal:
436
+ """Calculate total unrealized P&L (value - cost_basis)."""
437
+ holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
438
+ return holdings_value - self.total_cost_basis
439
+
440
+ @computed_field
441
+ @property
442
+ def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
443
+ """Calculate total unrealized P&L percentage."""
444
+ if self.total_cost_basis == 0:
445
+ return None
446
+ return round((self.total_unrealized_gain_loss / self.total_cost_basis) * 100, 2)
381
447
 
382
448
 
383
449
  class AssetAllocation(BaseModel):
384
450
  """Asset allocation breakdown by security type and sector.
385
-
451
+
386
452
  Provides percentage breakdown of portfolio by security type and sector.
387
453
  Used for diversification analysis and portfolio visualization.
388
-
454
+
389
455
  Example:
390
456
  >>> allocation = AssetAllocation(
391
457
  ... by_security_type={
@@ -421,11 +487,11 @@ class AssetAllocation(BaseModel):
421
487
  },
422
488
  )
423
489
 
424
- by_security_type: Dict[SecurityType, float] = Field(
490
+ by_security_type: dict[SecurityType, float] = Field(
425
491
  default_factory=dict,
426
492
  description="Percentage breakdown by security type (equity, bond, etc.)",
427
493
  )
428
- by_sector: Dict[str, float] = Field(
494
+ by_sector: dict[str, float] = Field(
429
495
  default_factory=dict,
430
496
  description="Percentage breakdown by sector (Technology, Healthcare, etc.)",
431
497
  )
@@ -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,9 +76,7 @@ class InvestmentProvider(ABC):
77
76
  pass
78
77
 
79
78
  @abstractmethod
80
- async def get_securities(
81
- self, access_token: str, security_ids: List[str]
82
- ) -> List[Security]:
79
+ async def get_securities(self, access_token: str, security_ids: list[str]) -> list[Security]:
83
80
  """Fetch security details (ticker, name, type, current price).
84
81
 
85
82
  Args:
@@ -97,9 +94,7 @@ class InvestmentProvider(ABC):
97
94
  pass
98
95
 
99
96
  @abstractmethod
100
- async def get_investment_accounts(
101
- self, access_token: str
102
- ) -> List[InvestmentAccount]:
97
+ async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
103
98
  """Fetch investment accounts with aggregated holdings.
104
99
 
105
100
  Args:
@@ -117,7 +112,7 @@ class InvestmentProvider(ABC):
117
112
 
118
113
  # Helper methods (concrete - shared across all providers)
119
114
 
120
- def calculate_allocation(self, holdings: List[Holding]) -> AssetAllocation:
115
+ def calculate_allocation(self, holdings: list[Holding]) -> AssetAllocation:
121
116
  """Calculate asset allocation by security type and sector.
122
117
 
123
118
  Groups holdings by security type (equity, bond, ETF, etc.) and calculates
@@ -176,8 +171,7 @@ class InvestmentProvider(ABC):
176
171
  }
177
172
 
178
173
  by_sector_percent = {
179
- sector: round((value / total_value) * 100, 2)
180
- for sector, value in sector_values.items()
174
+ sector: round((value / total_value) * 100, 2) for sector, value in sector_values.items()
181
175
  }
182
176
 
183
177
  cash_percent = round((cash_value / total_value) * 100, 2)
@@ -188,7 +182,7 @@ class InvestmentProvider(ABC):
188
182
  cash_percent=cash_percent,
189
183
  )
190
184
 
191
- def calculate_portfolio_metrics(self, holdings: List[Holding]) -> dict:
185
+ def calculate_portfolio_metrics(self, holdings: list[Holding]) -> dict:
192
186
  """Calculate total value, cost basis, unrealized gain/loss.
193
187
 
194
188
  Aggregates holdings to calculate portfolio-level metrics.
@@ -10,33 +10,55 @@ 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
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,
31
45
  InvestmentAccount,
32
46
  InvestmentTransaction,
33
47
  Security,
34
- SecurityType,
35
48
  TransactionType,
36
49
  )
37
50
  from .base import InvestmentProvider
38
51
 
39
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
+
40
62
  class PlaidInvestmentProvider(InvestmentProvider):
41
63
  """Plaid Investment API provider.
42
64
 
@@ -77,7 +99,10 @@ class PlaidInvestmentProvider(InvestmentProvider):
77
99
 
78
100
  Raises:
79
101
  ValueError: If client_id or secret is missing
102
+ ImportError: If plaid-python is not installed
80
103
  """
104
+ _require_plaid()
105
+
81
106
  if not client_id or not secret:
82
107
  raise ValueError("client_id and secret are required for Plaid provider")
83
108
 
@@ -103,11 +128,11 @@ class PlaidInvestmentProvider(InvestmentProvider):
103
128
  "development": plaid.Environment.Sandbox, # Map development to sandbox
104
129
  "production": plaid.Environment.Production,
105
130
  }
106
- return hosts.get(environment.lower(), plaid.Environment.Sandbox)
131
+ return cast("str", hosts.get(environment.lower(), plaid.Environment.Sandbox))
107
132
 
108
133
  async def get_holdings(
109
- self, access_token: str, account_ids: Optional[List[str]] = None
110
- ) -> List[Holding]:
134
+ self, access_token: str, account_ids: list[str] | None = None
135
+ ) -> list[Holding]:
111
136
  """Fetch investment holdings from Plaid.
112
137
 
113
138
  Retrieves holdings with security details, quantity, cost basis, and current value.
@@ -136,9 +161,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
136
161
  if account_ids:
137
162
  request.options = {"account_ids": account_ids}
138
163
 
139
- response: InvestmentsHoldingsGetResponse = (
140
- self.client.investments_holdings_get(request)
141
- )
164
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
142
165
 
143
166
  # Build security lookup map
144
167
  securities_map = {
@@ -166,8 +189,8 @@ class PlaidInvestmentProvider(InvestmentProvider):
166
189
  access_token: str,
167
190
  start_date: date,
168
191
  end_date: date,
169
- account_ids: Optional[List[str]] = None,
170
- ) -> List[InvestmentTransaction]:
192
+ account_ids: list[str] | None = None,
193
+ ) -> list[InvestmentTransaction]:
171
194
  """Fetch investment transactions from Plaid.
172
195
 
173
196
  Retrieves buy/sell/dividend transactions within the specified date range.
@@ -204,8 +227,8 @@ class PlaidInvestmentProvider(InvestmentProvider):
204
227
  if account_ids:
205
228
  request.options = {"account_ids": account_ids}
206
229
 
207
- response: InvestmentsTransactionsGetResponse = (
208
- self.client.investments_transactions_get(request)
230
+ response: InvestmentsTransactionsGetResponse = self.client.investments_transactions_get(
231
+ request
209
232
  )
210
233
 
211
234
  # Build security lookup map
@@ -229,9 +252,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
229
252
  except ApiException as e:
230
253
  raise self._transform_error(e)
231
254
 
232
- async def get_securities(
233
- self, access_token: str, security_ids: List[str]
234
- ) -> List[Security]:
255
+ async def get_securities(self, access_token: str, security_ids: list[str]) -> list[Security]:
235
256
  """Fetch security details from Plaid holdings.
236
257
 
237
258
  Note: Plaid doesn't have a dedicated securities endpoint.
@@ -254,9 +275,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
254
275
  """
255
276
  try:
256
277
  request = InvestmentsHoldingsGetRequest(access_token=access_token)
257
- response: InvestmentsHoldingsGetResponse = (
258
- self.client.investments_holdings_get(request)
259
- )
278
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
260
279
 
261
280
  # Filter securities by requested IDs
262
281
  securities = []
@@ -271,9 +290,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
271
290
  except ApiException as e:
272
291
  raise self._transform_error(e)
273
292
 
274
- async def get_investment_accounts(
275
- self, access_token: str
276
- ) -> List[InvestmentAccount]:
293
+ async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
277
294
  """Fetch investment accounts with aggregated holdings.
278
295
 
279
296
  Returns accounts with total value, cost basis, and unrealized P&L.
@@ -295,9 +312,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
295
312
  """
296
313
  try:
297
314
  request = InvestmentsHoldingsGetRequest(access_token=access_token)
298
- response: InvestmentsHoldingsGetResponse = (
299
- self.client.investments_holdings_get(request)
300
- )
315
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
301
316
 
302
317
  # Build security lookup map
303
318
  securities_map = {
@@ -306,7 +321,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
306
321
  }
307
322
 
308
323
  # Group holdings by account
309
- accounts_map: Dict[str, Dict[str, Any]] = {}
324
+ accounts_map: dict[str, dict[str, Any]] = {}
310
325
  for plaid_holding in response.holdings:
311
326
  holding_dict = plaid_holding.to_dict()
312
327
  account_id = holding_dict["account_id"]
@@ -318,11 +333,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
318
333
  if account_id not in accounts_map:
319
334
  # Find account metadata
320
335
  plaid_account = next(
321
- (
322
- acc
323
- for acc in response.accounts
324
- if acc.account_id == account_id
325
- ),
336
+ (acc for acc in response.accounts if acc.account_id == account_id),
326
337
  None,
327
338
  )
328
339
  accounts_map[account_id] = {
@@ -340,12 +351,16 @@ class PlaidInvestmentProvider(InvestmentProvider):
340
351
 
341
352
  investment_account = InvestmentAccount(
342
353
  account_id=account_id,
343
- name=account_dict.get("name", account_dict.get("official_name", "Unknown Account")),
354
+ name=account_dict.get(
355
+ "name", account_dict.get("official_name", "Unknown Account")
356
+ ),
344
357
  type=account_dict.get("type", "investment"),
345
358
  subtype=account_dict.get("subtype"),
346
359
  balances={
347
- "current": float(account_dict.get("balances", {}).get("current", 0)),
348
- "available": float(account_dict.get("balances", {}).get("available")),
360
+ "current": Decimal(str(account_dict.get("balances", {}).get("current", 0))),
361
+ "available": Decimal(
362
+ str(account_dict.get("balances", {}).get("available") or 0)
363
+ ),
349
364
  },
350
365
  holdings=holdings,
351
366
  )
@@ -358,19 +373,19 @@ class PlaidInvestmentProvider(InvestmentProvider):
358
373
 
359
374
  # Helper methods for data transformation
360
375
 
361
- def _transform_security(self, plaid_security: Dict[str, Any]) -> Security:
376
+ def _transform_security(self, plaid_security: dict[str, Any]) -> Security:
362
377
  """Transform Plaid security data to Security model."""
363
378
  # Handle close_price - Plaid may return None for securities without recent pricing
364
379
  close_price_raw = plaid_security.get("close_price")
365
380
  close_price = Decimal(str(close_price_raw)) if close_price_raw is not None else Decimal("0")
366
-
381
+
367
382
  return Security(
368
383
  security_id=plaid_security["security_id"],
369
384
  cusip=plaid_security.get("cusip"),
370
385
  isin=plaid_security.get("isin"),
371
386
  sedol=plaid_security.get("sedol"),
372
387
  ticker_symbol=plaid_security.get("ticker_symbol"),
373
- name=plaid_security.get("name"),
388
+ name=plaid_security.get("name") or "Unknown Security",
374
389
  type=self._normalize_security_type(plaid_security.get("type", "other")),
375
390
  sector=plaid_security.get("sector"),
376
391
  close_price=close_price,
@@ -379,9 +394,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
379
394
  currency=plaid_security.get("iso_currency_code", "USD"),
380
395
  )
381
396
 
382
- def _transform_holding(
383
- self, plaid_holding: Dict[str, Any], security: Security
384
- ) -> Holding:
397
+ def _transform_holding(self, plaid_holding: dict[str, Any], security: Security) -> Holding:
385
398
  """Transform Plaid holding data to Holding model."""
386
399
  return Holding(
387
400
  account_id=plaid_holding["account_id"],
@@ -389,13 +402,15 @@ class PlaidInvestmentProvider(InvestmentProvider):
389
402
  quantity=Decimal(str(plaid_holding.get("quantity", 0))),
390
403
  institution_price=Decimal(str(plaid_holding.get("institution_price", 0))),
391
404
  institution_value=Decimal(str(plaid_holding.get("institution_value", 0))),
392
- cost_basis=Decimal(str(plaid_holding.get("cost_basis"))) if plaid_holding.get("cost_basis") else None,
405
+ cost_basis=Decimal(str(plaid_holding.get("cost_basis")))
406
+ if plaid_holding.get("cost_basis")
407
+ else None,
393
408
  currency=plaid_holding.get("iso_currency_code", "USD"),
394
409
  unofficial_currency_code=plaid_holding.get("unofficial_currency_code"),
395
410
  )
396
411
 
397
412
  def _transform_transaction(
398
- self, plaid_transaction: Dict[str, Any], security: Security
413
+ self, plaid_transaction: dict[str, Any], security: Security
399
414
  ) -> InvestmentTransaction:
400
415
  """Transform Plaid investment transaction to InvestmentTransaction model."""
401
416
  # Map Plaid transaction type to our enum