fin-infra 0.1.62__py3-none-any.whl → 0.1.69__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/cash_flow.py +6 -5
  3. fin_infra/analytics/portfolio.py +13 -20
  4. fin_infra/analytics/rebalancing.py +2 -4
  5. fin_infra/analytics/savings.py +1 -1
  6. fin_infra/analytics/spending.py +15 -11
  7. fin_infra/banking/__init__.py +8 -5
  8. fin_infra/banking/history.py +3 -3
  9. fin_infra/banking/utils.py +93 -88
  10. fin_infra/brokerage/__init__.py +5 -3
  11. fin_infra/budgets/tracker.py +2 -3
  12. fin_infra/cashflows/__init__.py +6 -8
  13. fin_infra/categorization/__init__.py +1 -1
  14. fin_infra/categorization/add.py +15 -16
  15. fin_infra/categorization/ease.py +3 -4
  16. fin_infra/categorization/engine.py +4 -4
  17. fin_infra/categorization/llm_layer.py +5 -6
  18. fin_infra/categorization/models.py +1 -1
  19. fin_infra/chat/__init__.py +7 -16
  20. fin_infra/chat/planning.py +57 -0
  21. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  22. fin_infra/compliance/__init__.py +3 -3
  23. fin_infra/credit/add.py +3 -2
  24. fin_infra/credit/experian/auth.py +3 -2
  25. fin_infra/credit/experian/client.py +2 -2
  26. fin_infra/credit/experian/provider.py +16 -16
  27. fin_infra/crypto/__init__.py +1 -1
  28. fin_infra/crypto/insights.py +1 -3
  29. fin_infra/documents/add.py +5 -5
  30. fin_infra/documents/ease.py +4 -3
  31. fin_infra/documents/models.py +3 -3
  32. fin_infra/documents/ocr.py +1 -1
  33. fin_infra/documents/storage.py +2 -1
  34. fin_infra/exceptions.py +1 -1
  35. fin_infra/goals/add.py +2 -2
  36. fin_infra/goals/management.py +6 -6
  37. fin_infra/goals/milestones.py +2 -2
  38. fin_infra/insights/__init__.py +7 -8
  39. fin_infra/investments/__init__.py +13 -8
  40. fin_infra/investments/add.py +39 -59
  41. fin_infra/investments/ease.py +16 -13
  42. fin_infra/investments/models.py +130 -64
  43. fin_infra/investments/providers/base.py +3 -8
  44. fin_infra/investments/providers/plaid.py +23 -34
  45. fin_infra/investments/providers/snaptrade.py +22 -40
  46. fin_infra/markets/__init__.py +11 -8
  47. fin_infra/models/accounts.py +2 -1
  48. fin_infra/models/transactions.py +3 -2
  49. fin_infra/net_worth/add.py +8 -5
  50. fin_infra/net_worth/aggregator.py +5 -4
  51. fin_infra/net_worth/calculator.py +8 -6
  52. fin_infra/net_worth/ease.py +36 -15
  53. fin_infra/net_worth/insights.py +4 -4
  54. fin_infra/net_worth/models.py +237 -116
  55. fin_infra/normalization/__init__.py +15 -13
  56. fin_infra/normalization/providers/exchangerate.py +3 -3
  57. fin_infra/obs/classifier.py +2 -2
  58. fin_infra/providers/banking/plaid_client.py +20 -19
  59. fin_infra/providers/banking/teller_client.py +13 -7
  60. fin_infra/providers/base.py +105 -13
  61. fin_infra/providers/brokerage/alpaca.py +7 -7
  62. fin_infra/providers/credit/experian.py +5 -0
  63. fin_infra/providers/market/ccxt_crypto.py +8 -3
  64. fin_infra/providers/tax/mock.py +3 -3
  65. fin_infra/recurring/add.py +20 -9
  66. fin_infra/recurring/detector.py +1 -1
  67. fin_infra/recurring/detectors_llm.py +10 -9
  68. fin_infra/recurring/ease.py +1 -1
  69. fin_infra/recurring/insights.py +9 -8
  70. fin_infra/recurring/models.py +3 -3
  71. fin_infra/recurring/normalizer.py +3 -2
  72. fin_infra/recurring/normalizers.py +9 -8
  73. fin_infra/scaffold/__init__.py +1 -1
  74. fin_infra/security/encryption.py +2 -2
  75. fin_infra/security/pii_patterns.py +1 -1
  76. fin_infra/security/token_store.py +3 -1
  77. fin_infra/tax/__init__.py +1 -1
  78. fin_infra/utils/http.py +3 -2
  79. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
  80. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
  81. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
  82. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
  83. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
@@ -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, Dict, List, 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,
@@ -350,42 +381,77 @@ class InvestmentAccount(BaseModel):
350
381
  # Holdings
351
382
  holdings: List[Holding] = Field(default_factory=list, description="List of holdings in account")
352
383
 
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)
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={
@@ -77,9 +77,7 @@ class InvestmentProvider(ABC):
77
77
  pass
78
78
 
79
79
  @abstractmethod
80
- async def get_securities(
81
- self, access_token: str, security_ids: List[str]
82
- ) -> List[Security]:
80
+ async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
83
81
  """Fetch security details (ticker, name, type, current price).
84
82
 
85
83
  Args:
@@ -97,9 +95,7 @@ class InvestmentProvider(ABC):
97
95
  pass
98
96
 
99
97
  @abstractmethod
100
- async def get_investment_accounts(
101
- self, access_token: str
102
- ) -> List[InvestmentAccount]:
98
+ async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
103
99
  """Fetch investment accounts with aggregated holdings.
104
100
 
105
101
  Args:
@@ -176,8 +172,7 @@ class InvestmentProvider(ABC):
176
172
  }
177
173
 
178
174
  by_sector_percent = {
179
- sector: round((value / total_value) * 100, 2)
180
- for sector, value in sector_values.items()
175
+ sector: round((value / total_value) * 100, 2) for sector, value in sector_values.items()
181
176
  }
182
177
 
183
178
  cash_percent = round((cash_value / total_value) * 100, 2)
@@ -10,7 +10,7 @@ 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
13
+ from typing import Any, Dict, List, Optional, cast
14
14
 
15
15
  from plaid.api import plaid_api
16
16
  from plaid.model.investments_holdings_get_request import InvestmentsHoldingsGetRequest
@@ -31,7 +31,6 @@ from ..models import (
31
31
  InvestmentAccount,
32
32
  InvestmentTransaction,
33
33
  Security,
34
- SecurityType,
35
34
  TransactionType,
36
35
  )
37
36
  from .base import InvestmentProvider
@@ -103,7 +102,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
103
102
  "development": plaid.Environment.Sandbox, # Map development to sandbox
104
103
  "production": plaid.Environment.Production,
105
104
  }
106
- return hosts.get(environment.lower(), plaid.Environment.Sandbox)
105
+ return cast(str, hosts.get(environment.lower(), plaid.Environment.Sandbox))
107
106
 
108
107
  async def get_holdings(
109
108
  self, access_token: str, account_ids: Optional[List[str]] = None
@@ -136,9 +135,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
136
135
  if account_ids:
137
136
  request.options = {"account_ids": account_ids}
138
137
 
139
- response: InvestmentsHoldingsGetResponse = (
140
- self.client.investments_holdings_get(request)
141
- )
138
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
142
139
 
143
140
  # Build security lookup map
144
141
  securities_map = {
@@ -204,8 +201,8 @@ class PlaidInvestmentProvider(InvestmentProvider):
204
201
  if account_ids:
205
202
  request.options = {"account_ids": account_ids}
206
203
 
207
- response: InvestmentsTransactionsGetResponse = (
208
- self.client.investments_transactions_get(request)
204
+ response: InvestmentsTransactionsGetResponse = self.client.investments_transactions_get(
205
+ request
209
206
  )
210
207
 
211
208
  # Build security lookup map
@@ -229,9 +226,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
229
226
  except ApiException as e:
230
227
  raise self._transform_error(e)
231
228
 
232
- async def get_securities(
233
- self, access_token: str, security_ids: List[str]
234
- ) -> List[Security]:
229
+ async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
235
230
  """Fetch security details from Plaid holdings.
236
231
 
237
232
  Note: Plaid doesn't have a dedicated securities endpoint.
@@ -254,9 +249,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
254
249
  """
255
250
  try:
256
251
  request = InvestmentsHoldingsGetRequest(access_token=access_token)
257
- response: InvestmentsHoldingsGetResponse = (
258
- self.client.investments_holdings_get(request)
259
- )
252
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
260
253
 
261
254
  # Filter securities by requested IDs
262
255
  securities = []
@@ -271,9 +264,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
271
264
  except ApiException as e:
272
265
  raise self._transform_error(e)
273
266
 
274
- async def get_investment_accounts(
275
- self, access_token: str
276
- ) -> List[InvestmentAccount]:
267
+ async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
277
268
  """Fetch investment accounts with aggregated holdings.
278
269
 
279
270
  Returns accounts with total value, cost basis, and unrealized P&L.
@@ -295,9 +286,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
295
286
  """
296
287
  try:
297
288
  request = InvestmentsHoldingsGetRequest(access_token=access_token)
298
- response: InvestmentsHoldingsGetResponse = (
299
- self.client.investments_holdings_get(request)
300
- )
289
+ response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
301
290
 
302
291
  # Build security lookup map
303
292
  securities_map = {
@@ -318,11 +307,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
318
307
  if account_id not in accounts_map:
319
308
  # Find account metadata
320
309
  plaid_account = next(
321
- (
322
- acc
323
- for acc in response.accounts
324
- if acc.account_id == account_id
325
- ),
310
+ (acc for acc in response.accounts if acc.account_id == account_id),
326
311
  None,
327
312
  )
328
313
  accounts_map[account_id] = {
@@ -340,12 +325,16 @@ class PlaidInvestmentProvider(InvestmentProvider):
340
325
 
341
326
  investment_account = InvestmentAccount(
342
327
  account_id=account_id,
343
- name=account_dict.get("name", account_dict.get("official_name", "Unknown Account")),
328
+ name=account_dict.get(
329
+ "name", account_dict.get("official_name", "Unknown Account")
330
+ ),
344
331
  type=account_dict.get("type", "investment"),
345
332
  subtype=account_dict.get("subtype"),
346
333
  balances={
347
- "current": float(account_dict.get("balances", {}).get("current", 0)),
348
- "available": float(account_dict.get("balances", {}).get("available")),
334
+ "current": Decimal(str(account_dict.get("balances", {}).get("current", 0))),
335
+ "available": Decimal(
336
+ str(account_dict.get("balances", {}).get("available") or 0)
337
+ ),
349
338
  },
350
339
  holdings=holdings,
351
340
  )
@@ -363,14 +352,14 @@ class PlaidInvestmentProvider(InvestmentProvider):
363
352
  # Handle close_price - Plaid may return None for securities without recent pricing
364
353
  close_price_raw = plaid_security.get("close_price")
365
354
  close_price = Decimal(str(close_price_raw)) if close_price_raw is not None else Decimal("0")
366
-
355
+
367
356
  return Security(
368
357
  security_id=plaid_security["security_id"],
369
358
  cusip=plaid_security.get("cusip"),
370
359
  isin=plaid_security.get("isin"),
371
360
  sedol=plaid_security.get("sedol"),
372
361
  ticker_symbol=plaid_security.get("ticker_symbol"),
373
- name=plaid_security.get("name"),
362
+ name=plaid_security.get("name") or "Unknown Security",
374
363
  type=self._normalize_security_type(plaid_security.get("type", "other")),
375
364
  sector=plaid_security.get("sector"),
376
365
  close_price=close_price,
@@ -379,9 +368,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
379
368
  currency=plaid_security.get("iso_currency_code", "USD"),
380
369
  )
381
370
 
382
- def _transform_holding(
383
- self, plaid_holding: Dict[str, Any], security: Security
384
- ) -> Holding:
371
+ def _transform_holding(self, plaid_holding: Dict[str, Any], security: Security) -> Holding:
385
372
  """Transform Plaid holding data to Holding model."""
386
373
  return Holding(
387
374
  account_id=plaid_holding["account_id"],
@@ -389,7 +376,9 @@ class PlaidInvestmentProvider(InvestmentProvider):
389
376
  quantity=Decimal(str(plaid_holding.get("quantity", 0))),
390
377
  institution_price=Decimal(str(plaid_holding.get("institution_price", 0))),
391
378
  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,
379
+ cost_basis=Decimal(str(plaid_holding.get("cost_basis")))
380
+ if plaid_holding.get("cost_basis")
381
+ else None,
393
382
  currency=plaid_holding.get("iso_currency_code", "USD"),
394
383
  unofficial_currency_code=plaid_holding.get("unofficial_currency_code"),
395
384
  )
@@ -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
14
+ from typing import Any, Dict, List, Optional, cast
15
15
 
16
16
  import httpx
17
17
 
@@ -77,9 +77,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
77
77
  ValueError: If client_id or consumer_key is missing
78
78
  """
79
79
  if not client_id or not consumer_key:
80
- raise ValueError(
81
- "client_id and consumer_key are required for SnapTrade provider"
82
- )
80
+ raise ValueError("client_id and consumer_key are required for SnapTrade provider")
83
81
 
84
82
  self.client_id = client_id
85
83
  self.consumer_key = consumer_key
@@ -97,15 +95,15 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
97
95
 
98
96
  def _auth_headers(self, user_id: str, user_secret: str) -> Dict[str, str]:
99
97
  """Build authentication headers for SnapTrade API requests.
100
-
98
+
101
99
  SECURITY: User secrets are passed in headers, NOT URL params.
102
100
  URL params are logged in access logs, browser history, and proxy logs.
103
101
  Headers are not logged by default in most web servers.
104
-
102
+
105
103
  Args:
106
104
  user_id: SnapTrade user ID
107
105
  user_secret: SnapTrade user secret (sensitive!)
108
-
106
+
109
107
  Returns:
110
108
  Dict with authentication headers
111
109
  """
@@ -158,9 +156,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
158
156
  all_holdings = []
159
157
  for account in accounts:
160
158
  account_id = account["id"]
161
- positions_url = (
162
- f"{self.base_url}/accounts/{account_id}/positions"
163
- )
159
+ positions_url = f"{self.base_url}/accounts/{account_id}/positions"
164
160
  pos_response = await self.client.get(positions_url, headers=auth_headers)
165
161
  pos_response.raise_for_status()
166
162
  positions = await pos_response.json()
@@ -226,15 +222,15 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
226
222
  all_transactions = []
227
223
  for account in accounts:
228
224
  account_id = account["id"]
229
- transactions_url = (
230
- f"{self.base_url}/accounts/{account_id}/transactions"
231
- )
225
+ transactions_url = f"{self.base_url}/accounts/{account_id}/transactions"
232
226
  # Date params are non-sensitive, only auth goes in headers
233
227
  tx_params = {
234
228
  "startDate": start_date.isoformat(),
235
229
  "endDate": end_date.isoformat(),
236
230
  }
237
- tx_response = await self.client.get(transactions_url, params=tx_params, headers=auth_headers)
231
+ tx_response = await self.client.get(
232
+ transactions_url, params=tx_params, headers=auth_headers
233
+ )
238
234
  tx_response.raise_for_status()
239
235
  transactions = await tx_response.json()
240
236
 
@@ -250,9 +246,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
250
246
  except Exception as e:
251
247
  raise ValueError(f"SnapTrade API error: {str(e)}")
252
248
 
253
- async def get_securities(
254
- self, access_token: str, security_ids: List[str]
255
- ) -> List[Security]:
249
+ async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
256
250
  """Fetch security details from SnapTrade positions.
257
251
 
258
252
  Note: SnapTrade doesn't have a dedicated securities endpoint.
@@ -290,9 +284,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
290
284
  except Exception as e:
291
285
  raise ValueError(f"SnapTrade API error: {str(e)}")
292
286
 
293
- async def get_investment_accounts(
294
- self, access_token: str
295
- ) -> List[InvestmentAccount]:
287
+ async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
296
288
  """Fetch investment accounts with aggregated holdings.
297
289
 
298
290
  Returns accounts with total value, cost basis, and unrealized P&L.
@@ -328,9 +320,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
328
320
  account_id = account["id"]
329
321
 
330
322
  # Get positions for this account
331
- positions_url = (
332
- f"{self.base_url}/accounts/{account_id}/positions"
333
- )
323
+ positions_url = f"{self.base_url}/accounts/{account_id}/positions"
334
324
  pos_response = await self.client.get(positions_url, headers=auth_headers)
335
325
  pos_response.raise_for_status()
336
326
  positions = await pos_response.json()
@@ -354,8 +344,8 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
354
344
  type=account.get("type", "investment"),
355
345
  subtype=account.get("account_type"),
356
346
  balances={
357
- "current": float(balances.get("total", {}).get("amount", 0)),
358
- "available": float(balances.get("cash", {}).get("amount", 0)),
347
+ "current": Decimal(str(balances.get("total", {}).get("amount", 0))),
348
+ "available": Decimal(str(balances.get("cash", {}).get("amount", 0))),
359
349
  },
360
350
  holdings=holdings,
361
351
  )
@@ -368,9 +358,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
368
358
  except Exception as e:
369
359
  raise ValueError(f"SnapTrade API error: {str(e)}")
370
360
 
371
- async def list_connections(
372
- self, access_token: str
373
- ) -> List[Dict[str, Any]]:
361
+ async def list_connections(self, access_token: str) -> List[Dict[str, Any]]:
374
362
  """List brokerage connections for a user.
375
363
 
376
364
  Returns which brokerages the user has connected (E*TRADE, Robinhood, etc.).
@@ -393,7 +381,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
393
381
  url = f"{self.base_url}/connections"
394
382
  response = await self.client.get(url, headers=auth_headers)
395
383
  response.raise_for_status()
396
- return await response.json()
384
+ return cast(list[dict[str, Any]], await response.json())
397
385
 
398
386
  except httpx.HTTPStatusError as e:
399
387
  raise self._transform_error(e)
@@ -490,16 +478,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
490
478
  user_id, user_secret = access_token.split(":", 1)
491
479
  return user_id, user_secret
492
480
  except ValueError:
493
- raise ValueError(
494
- "Invalid access_token format. Expected 'user_id:user_secret'"
495
- )
481
+ raise ValueError("Invalid access_token format. Expected 'user_id:user_secret'")
496
482
 
497
- def _transform_holding(
498
- self, snaptrade_position: Dict[str, Any], account_id: str
499
- ) -> Holding:
483
+ def _transform_holding(self, snaptrade_position: Dict[str, Any], account_id: str) -> Holding:
500
484
  """Transform SnapTrade position data to Holding model."""
501
485
  symbol_data = snaptrade_position.get("symbol", {})
502
-
486
+
503
487
  # Create Security from symbol data
504
488
  security = Security(
505
489
  security_id=symbol_data.get("id", symbol_data.get("symbol", "")),
@@ -513,9 +497,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
513
497
  # SnapTrade uses "average_purchase_price" for cost basis
514
498
  avg_price = snaptrade_position.get("average_purchase_price")
515
499
  quantity = Decimal(str(snaptrade_position.get("units", 0)))
516
- cost_basis = (
517
- Decimal(str(avg_price)) * quantity if avg_price is not None else None
518
- )
500
+ cost_basis = Decimal(str(avg_price)) * quantity if avg_price is not None else None
519
501
 
520
502
  return Holding(
521
503
  account_id=account_id,
@@ -532,7 +514,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
532
514
  ) -> InvestmentTransaction:
533
515
  """Transform SnapTrade transaction to InvestmentTransaction model."""
534
516
  symbol_data = snaptrade_tx.get("symbol", {})
535
-
517
+
536
518
  # Create Security from symbol data
537
519
  security = Security(
538
520
  security_id=symbol_data.get("id", symbol_data.get("symbol", "")),