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.
- fin_infra/analytics/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
fin_infra/investments/models.py
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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": {
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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(
|
|
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":
|
|
348
|
-
"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")))
|
|
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(
|
|
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":
|
|
358
|
-
"available":
|
|
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", "")),
|