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