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.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +30 -32
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +19 -26
- fin_infra/analytics/projections.py +1 -3
- 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 +33 -31
- fin_infra/banking/history.py +11 -12
- fin_infra/banking/utils.py +116 -110
- fin_infra/brokerage/__init__.py +27 -27
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +3 -3
- fin_infra/budgets/tracker.py +4 -5
- fin_infra/cashflows/__init__.py +8 -10
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +17 -19
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +21 -18
- fin_infra/categorization/llm_layer.py +10 -10
- fin_infra/categorization/models.py +1 -1
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +13 -22
- fin_infra/chat/planning.py +57 -1
- fin_infra/cli/cmds/scaffold_cmds.py +11 -12
- fin_infra/clients/__init__.py +23 -1
- fin_infra/clients/base.py +1 -1
- fin_infra/clients/plaid.py +2 -2
- fin_infra/compliance/__init__.py +7 -6
- fin_infra/credit/add.py +7 -7
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +19 -19
- fin_infra/crypto/__init__.py +8 -10
- fin_infra/crypto/insights.py +5 -6
- fin_infra/documents/add.py +11 -13
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +18 -17
- fin_infra/documents/models.py +7 -7
- fin_infra/documents/ocr.py +8 -8
- fin_infra/documents/storage.py +23 -14
- fin_infra/exceptions.py +1 -2
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +36 -36
- fin_infra/goals/funding.py +4 -6
- fin_infra/goals/management.py +6 -7
- fin_infra/goals/milestones.py +2 -3
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +12 -10
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +14 -9
- fin_infra/investments/add.py +53 -73
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +135 -69
- fin_infra/investments/providers/base.py +9 -15
- fin_infra/investments/providers/plaid.py +70 -55
- fin_infra/investments/providers/snaptrade.py +35 -53
- fin_infra/markets/__init__.py +16 -11
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +4 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +9 -6
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -5
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +17 -15
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +23 -22
- fin_infra/providers/banking/teller_client.py +14 -7
- fin_infra/providers/base.py +131 -14
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +25 -4
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +23 -8
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +8 -8
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +24 -12
- fin_infra/recurring/detector.py +8 -8
- fin_infra/recurring/detectors_llm.py +14 -13
- fin_infra/recurring/ease.py +3 -5
- fin_infra/recurring/insights.py +20 -19
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +11 -10
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +5 -5
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +2 -2
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +5 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.62.dist-info/RECORD +0 -180
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.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
|
|
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(
|
|
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,
|
|
@@ -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:
|
|
377
|
+
balances: dict[str, Optional[Decimal]] = Field(
|
|
347
378
|
..., description="Current, available, and limit balances"
|
|
348
379
|
)
|
|
349
380
|
|
|
350
381
|
# Holdings
|
|
351
|
-
holdings:
|
|
352
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
34
|
-
) ->
|
|
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:
|
|
58
|
-
) ->
|
|
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:
|
|
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:
|
|
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,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from plaid.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
from plaid.
|
|
21
|
-
from plaid.model.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
from plaid.
|
|
27
|
-
|
|
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:
|
|
110
|
-
) ->
|
|
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:
|
|
170
|
-
) ->
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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":
|
|
348
|
-
"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:
|
|
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")))
|
|
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:
|
|
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
|