fin-infra 0.1.67__py3-none-any.whl → 0.1.68__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/portfolio.py +12 -18
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +3 -1
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +88 -82
- fin_infra/brokerage/__init__.py +1 -1
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/categorization/ease.py +2 -3
- fin_infra/categorization/llm_layer.py +2 -2
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/credit/experian/provider.py +14 -14
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/documents/add.py +4 -4
- 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/management.py +3 -3
- fin_infra/insights/__init__.py +0 -1
- fin_infra/investments/__init__.py +2 -4
- fin_infra/investments/add.py +37 -56
- fin_infra/investments/ease.py +7 -8
- fin_infra/investments/models.py +29 -17
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +19 -29
- fin_infra/investments/providers/snaptrade.py +18 -36
- fin_infra/markets/__init__.py +4 -2
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +2 -1
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +2 -2
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/normalization/__init__.py +3 -1
- fin_infra/providers/banking/plaid_client.py +16 -16
- fin_infra/providers/base.py +5 -5
- fin_infra/providers/brokerage/alpaca.py +2 -2
- fin_infra/providers/market/ccxt_crypto.py +4 -1
- fin_infra/recurring/add.py +3 -1
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/normalizer.py +1 -1
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/tax/__init__.py +1 -1
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.67.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
fin_infra/investments/models.py
CHANGED
|
@@ -27,7 +27,7 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
|
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
|
|
@@ -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,8 +195,12 @@ 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")
|
|
@@ -240,10 +246,10 @@ class Holding(BaseModel):
|
|
|
240
246
|
|
|
241
247
|
class InvestmentTransaction(BaseModel):
|
|
242
248
|
"""Investment transaction (buy, sell, dividend, etc.).
|
|
243
|
-
|
|
249
|
+
|
|
244
250
|
Represents a single transaction in an investment account.
|
|
245
251
|
Used to calculate realized gains and track transaction history.
|
|
246
|
-
|
|
252
|
+
|
|
247
253
|
Example:
|
|
248
254
|
>>> transaction = InvestmentTransaction(
|
|
249
255
|
... transaction_id="tx_123",
|
|
@@ -295,7 +301,9 @@ class InvestmentTransaction(BaseModel):
|
|
|
295
301
|
# Transaction details
|
|
296
302
|
transaction_date: date = Field(..., alias="date", description="Transaction date")
|
|
297
303
|
name: str = Field(..., description="Transaction description")
|
|
298
|
-
transaction_type: TransactionType = Field(
|
|
304
|
+
transaction_type: TransactionType = Field(
|
|
305
|
+
..., alias="type", description="Transaction type (buy, sell, dividend)"
|
|
306
|
+
)
|
|
299
307
|
subtype: Optional[str] = Field(None, description="Provider-specific subtype")
|
|
300
308
|
|
|
301
309
|
# Amounts
|
|
@@ -311,10 +319,10 @@ class InvestmentTransaction(BaseModel):
|
|
|
311
319
|
|
|
312
320
|
class InvestmentAccount(BaseModel):
|
|
313
321
|
"""Investment account with aggregated holdings and metrics.
|
|
314
|
-
|
|
322
|
+
|
|
315
323
|
Represents a complete investment account with all holdings, balances, and P&L.
|
|
316
324
|
Includes calculated fields for total value, cost basis, and unrealized gains.
|
|
317
|
-
|
|
325
|
+
|
|
318
326
|
Example:
|
|
319
327
|
>>> account = InvestmentAccount(
|
|
320
328
|
... account_id="acct_123",
|
|
@@ -340,7 +348,11 @@ class InvestmentAccount(BaseModel):
|
|
|
340
348
|
"holdings": [
|
|
341
349
|
{
|
|
342
350
|
"account_id": "acct_abc123",
|
|
343
|
-
"security": {
|
|
351
|
+
"security": {
|
|
352
|
+
"ticker_symbol": "AAPL",
|
|
353
|
+
"name": "Apple Inc.",
|
|
354
|
+
"type": "equity",
|
|
355
|
+
},
|
|
344
356
|
"quantity": 10.5,
|
|
345
357
|
"institution_price": 150.25,
|
|
346
358
|
"institution_value": 1577.63,
|
|
@@ -436,10 +448,10 @@ class InvestmentAccount(BaseModel):
|
|
|
436
448
|
|
|
437
449
|
class AssetAllocation(BaseModel):
|
|
438
450
|
"""Asset allocation breakdown by security type and sector.
|
|
439
|
-
|
|
451
|
+
|
|
440
452
|
Provides percentage breakdown of portfolio by security type and sector.
|
|
441
453
|
Used for diversification analysis and portfolio visualization.
|
|
442
|
-
|
|
454
|
+
|
|
443
455
|
Example:
|
|
444
456
|
>>> allocation = AssetAllocation(
|
|
445
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)
|
|
@@ -135,9 +135,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
135
135
|
if account_ids:
|
|
136
136
|
request.options = {"account_ids": account_ids}
|
|
137
137
|
|
|
138
|
-
response: InvestmentsHoldingsGetResponse = (
|
|
139
|
-
self.client.investments_holdings_get(request)
|
|
140
|
-
)
|
|
138
|
+
response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
|
|
141
139
|
|
|
142
140
|
# Build security lookup map
|
|
143
141
|
securities_map = {
|
|
@@ -203,8 +201,8 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
203
201
|
if account_ids:
|
|
204
202
|
request.options = {"account_ids": account_ids}
|
|
205
203
|
|
|
206
|
-
response: InvestmentsTransactionsGetResponse = (
|
|
207
|
-
|
|
204
|
+
response: InvestmentsTransactionsGetResponse = self.client.investments_transactions_get(
|
|
205
|
+
request
|
|
208
206
|
)
|
|
209
207
|
|
|
210
208
|
# Build security lookup map
|
|
@@ -228,9 +226,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
228
226
|
except ApiException as e:
|
|
229
227
|
raise self._transform_error(e)
|
|
230
228
|
|
|
231
|
-
async def get_securities(
|
|
232
|
-
self, access_token: str, security_ids: List[str]
|
|
233
|
-
) -> List[Security]:
|
|
229
|
+
async def get_securities(self, access_token: str, security_ids: List[str]) -> List[Security]:
|
|
234
230
|
"""Fetch security details from Plaid holdings.
|
|
235
231
|
|
|
236
232
|
Note: Plaid doesn't have a dedicated securities endpoint.
|
|
@@ -253,9 +249,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
253
249
|
"""
|
|
254
250
|
try:
|
|
255
251
|
request = InvestmentsHoldingsGetRequest(access_token=access_token)
|
|
256
|
-
response: InvestmentsHoldingsGetResponse = (
|
|
257
|
-
self.client.investments_holdings_get(request)
|
|
258
|
-
)
|
|
252
|
+
response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
|
|
259
253
|
|
|
260
254
|
# Filter securities by requested IDs
|
|
261
255
|
securities = []
|
|
@@ -270,9 +264,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
270
264
|
except ApiException as e:
|
|
271
265
|
raise self._transform_error(e)
|
|
272
266
|
|
|
273
|
-
async def get_investment_accounts(
|
|
274
|
-
self, access_token: str
|
|
275
|
-
) -> List[InvestmentAccount]:
|
|
267
|
+
async def get_investment_accounts(self, access_token: str) -> List[InvestmentAccount]:
|
|
276
268
|
"""Fetch investment accounts with aggregated holdings.
|
|
277
269
|
|
|
278
270
|
Returns accounts with total value, cost basis, and unrealized P&L.
|
|
@@ -294,9 +286,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
294
286
|
"""
|
|
295
287
|
try:
|
|
296
288
|
request = InvestmentsHoldingsGetRequest(access_token=access_token)
|
|
297
|
-
response: InvestmentsHoldingsGetResponse = (
|
|
298
|
-
self.client.investments_holdings_get(request)
|
|
299
|
-
)
|
|
289
|
+
response: InvestmentsHoldingsGetResponse = self.client.investments_holdings_get(request)
|
|
300
290
|
|
|
301
291
|
# Build security lookup map
|
|
302
292
|
securities_map = {
|
|
@@ -317,11 +307,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
317
307
|
if account_id not in accounts_map:
|
|
318
308
|
# Find account metadata
|
|
319
309
|
plaid_account = next(
|
|
320
|
-
(
|
|
321
|
-
acc
|
|
322
|
-
for acc in response.accounts
|
|
323
|
-
if acc.account_id == account_id
|
|
324
|
-
),
|
|
310
|
+
(acc for acc in response.accounts if acc.account_id == account_id),
|
|
325
311
|
None,
|
|
326
312
|
)
|
|
327
313
|
accounts_map[account_id] = {
|
|
@@ -339,12 +325,16 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
339
325
|
|
|
340
326
|
investment_account = InvestmentAccount(
|
|
341
327
|
account_id=account_id,
|
|
342
|
-
name=account_dict.get(
|
|
328
|
+
name=account_dict.get(
|
|
329
|
+
"name", account_dict.get("official_name", "Unknown Account")
|
|
330
|
+
),
|
|
343
331
|
type=account_dict.get("type", "investment"),
|
|
344
332
|
subtype=account_dict.get("subtype"),
|
|
345
333
|
balances={
|
|
346
334
|
"current": Decimal(str(account_dict.get("balances", {}).get("current", 0))),
|
|
347
|
-
"available": Decimal(
|
|
335
|
+
"available": Decimal(
|
|
336
|
+
str(account_dict.get("balances", {}).get("available") or 0)
|
|
337
|
+
),
|
|
348
338
|
},
|
|
349
339
|
holdings=holdings,
|
|
350
340
|
)
|
|
@@ -362,7 +352,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
362
352
|
# Handle close_price - Plaid may return None for securities without recent pricing
|
|
363
353
|
close_price_raw = plaid_security.get("close_price")
|
|
364
354
|
close_price = Decimal(str(close_price_raw)) if close_price_raw is not None else Decimal("0")
|
|
365
|
-
|
|
355
|
+
|
|
366
356
|
return Security(
|
|
367
357
|
security_id=plaid_security["security_id"],
|
|
368
358
|
cusip=plaid_security.get("cusip"),
|
|
@@ -378,9 +368,7 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
378
368
|
currency=plaid_security.get("iso_currency_code", "USD"),
|
|
379
369
|
)
|
|
380
370
|
|
|
381
|
-
def _transform_holding(
|
|
382
|
-
self, plaid_holding: Dict[str, Any], security: Security
|
|
383
|
-
) -> Holding:
|
|
371
|
+
def _transform_holding(self, plaid_holding: Dict[str, Any], security: Security) -> Holding:
|
|
384
372
|
"""Transform Plaid holding data to Holding model."""
|
|
385
373
|
return Holding(
|
|
386
374
|
account_id=plaid_holding["account_id"],
|
|
@@ -388,7 +376,9 @@ class PlaidInvestmentProvider(InvestmentProvider):
|
|
|
388
376
|
quantity=Decimal(str(plaid_holding.get("quantity", 0))),
|
|
389
377
|
institution_price=Decimal(str(plaid_holding.get("institution_price", 0))),
|
|
390
378
|
institution_value=Decimal(str(plaid_holding.get("institution_value", 0))),
|
|
391
|
-
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,
|
|
392
382
|
currency=plaid_holding.get("iso_currency_code", "USD"),
|
|
393
383
|
unofficial_currency_code=plaid_holding.get("unofficial_currency_code"),
|
|
394
384
|
)
|
|
@@ -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()
|
|
@@ -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.).
|
|
@@ -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", "")),
|
fin_infra/markets/__init__.py
CHANGED
|
@@ -93,7 +93,7 @@ def easy_market(
|
|
|
93
93
|
|
|
94
94
|
else:
|
|
95
95
|
raise ValueError(
|
|
96
|
-
f"Unknown market data provider: {provider_name}.
|
|
96
|
+
f"Unknown market data provider: {provider_name}. Supported: alphavantage, yahoo"
|
|
97
97
|
)
|
|
98
98
|
|
|
99
99
|
|
|
@@ -231,7 +231,9 @@ def add_market_data(
|
|
|
231
231
|
candles_list.append(candle.dict())
|
|
232
232
|
else:
|
|
233
233
|
# Cast to dict for type compatibility
|
|
234
|
-
candles_list.append(
|
|
234
|
+
candles_list.append(
|
|
235
|
+
dict(candle) if hasattr(candle, "__iter__") else {"data": candle}
|
|
236
|
+
)
|
|
235
237
|
return {"candles": candles_list}
|
|
236
238
|
except Exception as e:
|
|
237
239
|
raise HTTPException(status_code=400, detail=str(e))
|
fin_infra/models/accounts.py
CHANGED
|
@@ -18,10 +18,11 @@ class AccountType(str, Enum):
|
|
|
18
18
|
|
|
19
19
|
class Account(BaseModel):
|
|
20
20
|
"""Financial account model.
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
Uses Decimal for balance fields to prevent floating-point precision errors
|
|
23
23
|
in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
|
|
24
24
|
"""
|
|
25
|
+
|
|
25
26
|
id: str
|
|
26
27
|
name: str
|
|
27
28
|
type: AccountType
|
fin_infra/models/transactions.py
CHANGED
|
@@ -9,10 +9,11 @@ from pydantic import BaseModel, field_validator
|
|
|
9
9
|
|
|
10
10
|
class Transaction(BaseModel):
|
|
11
11
|
"""Financial transaction model.
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
Uses Decimal for amount to prevent floating-point precision errors
|
|
14
14
|
in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
|
|
15
15
|
"""
|
|
16
|
+
|
|
16
17
|
id: str
|
|
17
18
|
account_id: str
|
|
18
19
|
date: date
|
|
@@ -30,7 +30,6 @@ print(f"Net Worth: ${net_worth:,.2f}") # $55,000.00
|
|
|
30
30
|
```
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
|
|
34
33
|
from fin_infra.net_worth.models import (
|
|
35
34
|
AssetAllocation,
|
|
36
35
|
AssetCategory,
|
|
@@ -100,18 +99,19 @@ def calculate_net_worth(
|
|
|
100
99
|
|
|
101
100
|
Returns:
|
|
102
101
|
Net worth in base currency
|
|
103
|
-
|
|
102
|
+
|
|
104
103
|
Raises:
|
|
105
104
|
ValueError: If assets or liabilities contain non-base currencies and no
|
|
106
105
|
exchange rate conversion is available. This prevents silent data loss.
|
|
107
106
|
"""
|
|
108
107
|
import logging
|
|
108
|
+
|
|
109
109
|
logger = logging.getLogger(__name__)
|
|
110
|
-
|
|
110
|
+
|
|
111
111
|
# Collect any non-base currency items for error reporting
|
|
112
112
|
non_base_assets: list[tuple[str, str, float]] = []
|
|
113
113
|
non_base_liabilities: list[tuple[str, str, float]] = []
|
|
114
|
-
|
|
114
|
+
|
|
115
115
|
# Sum all assets (use market_value if available, otherwise balance)
|
|
116
116
|
total_assets = 0.0
|
|
117
117
|
for asset in assets:
|
|
@@ -130,7 +130,9 @@ def calculate_net_worth(
|
|
|
130
130
|
for liability in liabilities:
|
|
131
131
|
# Check for non-base currency
|
|
132
132
|
if liability.currency != base_currency:
|
|
133
|
-
non_base_liabilities.append(
|
|
133
|
+
non_base_liabilities.append(
|
|
134
|
+
(liability.name or liability.account_id, liability.currency, liability.balance)
|
|
135
|
+
)
|
|
134
136
|
continue
|
|
135
137
|
|
|
136
138
|
total_liabilities += liability.balance
|
|
@@ -143,7 +145,7 @@ def calculate_net_worth(
|
|
|
143
145
|
items_msg.append(f"Assets: {non_base_assets}")
|
|
144
146
|
if non_base_liabilities:
|
|
145
147
|
items_msg.append(f"Liabilities: {non_base_liabilities}")
|
|
146
|
-
|
|
148
|
+
|
|
147
149
|
error_msg = (
|
|
148
150
|
f"Cannot calculate net worth: found accounts in non-{base_currency} currencies. "
|
|
149
151
|
f"Currency conversion not yet implemented. {'; '.join(items_msg)}. "
|
fin_infra/net_worth/ease.py
CHANGED
|
@@ -380,7 +380,7 @@ def easy_net_worth(
|
|
|
380
380
|
from ai_infra.llm.llm import LLM
|
|
381
381
|
except ImportError:
|
|
382
382
|
raise ImportError(
|
|
383
|
-
"LLM features require ai-infra package.
|
|
383
|
+
"LLM features require ai-infra package. Install with: pip install ai-infra"
|
|
384
384
|
)
|
|
385
385
|
|
|
386
386
|
cache = None
|
|
@@ -401,7 +401,7 @@ def easy_net_worth(
|
|
|
401
401
|
|
|
402
402
|
if not model_name:
|
|
403
403
|
raise ValueError(
|
|
404
|
-
f"Unknown llm_provider: {llm_provider}.
|
|
404
|
+
f"Unknown llm_provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'"
|
|
405
405
|
)
|
|
406
406
|
|
|
407
407
|
# Create shared LLM instance
|
fin_infra/net_worth/insights.py
CHANGED
|
@@ -479,13 +479,13 @@ class NetWorthInsightsGenerator:
|
|
|
479
479
|
|
|
480
480
|
user_prompt = f"""Analyze wealth trends:
|
|
481
481
|
|
|
482
|
-
Current net worth: ${current[
|
|
483
|
-
Previous net worth: ${previous[
|
|
482
|
+
Current net worth: ${current["total_net_worth"]:,.0f}
|
|
483
|
+
Previous net worth: ${previous["total_net_worth"]:,.0f}
|
|
484
484
|
Period: {period}
|
|
485
485
|
Change: ${change_amount:,.0f} ({change_percent:.1%})
|
|
486
486
|
|
|
487
|
-
Assets: ${current[
|
|
488
|
-
Liabilities: ${current[
|
|
487
|
+
Assets: ${current["total_assets"]:,.0f}
|
|
488
|
+
Liabilities: ${current["total_liabilities"]:,.0f}
|
|
489
489
|
|
|
490
490
|
Identify drivers of change, risk factors, and recommendations."""
|
|
491
491
|
|
|
@@ -145,7 +145,9 @@ def add_normalization(
|
|
|
145
145
|
@router.get("/convert")
|
|
146
146
|
async def convert_currency(
|
|
147
147
|
amount: float = Query(..., description="Amount to convert"),
|
|
148
|
-
from_currency: str = Query(
|
|
148
|
+
from_currency: str = Query(
|
|
149
|
+
..., alias="from", description="Source currency code (e.g., USD)"
|
|
150
|
+
),
|
|
149
151
|
to_currency: str = Query(..., alias="to", description="Target currency code (e.g., EUR)"),
|
|
150
152
|
):
|
|
151
153
|
"""Convert amount between currencies."""
|