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
|
@@ -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
|
|
|
@@ -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
|
|
@@ -95,17 +93,17 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
95
93
|
timeout=30.0,
|
|
96
94
|
)
|
|
97
95
|
|
|
98
|
-
def _auth_headers(self, user_id: str, user_secret: str) ->
|
|
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
|
"""
|
|
@@ -117,8 +115,8 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
117
115
|
async def get_holdings(
|
|
118
116
|
self,
|
|
119
117
|
access_token: str,
|
|
120
|
-
account_ids:
|
|
121
|
-
) ->
|
|
118
|
+
account_ids: list[str] | None = None,
|
|
119
|
+
) -> list[Holding]:
|
|
122
120
|
"""Fetch investment holdings from SnapTrade.
|
|
123
121
|
|
|
124
122
|
Note: SnapTrade uses user_id + user_secret, passed as access_token in format "user_id:user_secret"
|
|
@@ -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()
|
|
@@ -175,15 +171,15 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
175
171
|
except httpx.HTTPStatusError as e:
|
|
176
172
|
raise self._transform_error(e)
|
|
177
173
|
except Exception as e:
|
|
178
|
-
raise ValueError(f"SnapTrade API error: {
|
|
174
|
+
raise ValueError(f"SnapTrade API error: {e!s}")
|
|
179
175
|
|
|
180
176
|
async def get_transactions(
|
|
181
177
|
self,
|
|
182
178
|
access_token: str,
|
|
183
179
|
start_date: date,
|
|
184
180
|
end_date: date,
|
|
185
|
-
account_ids:
|
|
186
|
-
) ->
|
|
181
|
+
account_ids: list[str] | None = None,
|
|
182
|
+
) -> list[InvestmentTransaction]:
|
|
187
183
|
"""Fetch investment transactions from SnapTrade.
|
|
188
184
|
|
|
189
185
|
Args:
|
|
@@ -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
|
|
|
@@ -248,11 +244,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
248
244
|
except httpx.HTTPStatusError as e:
|
|
249
245
|
raise self._transform_error(e)
|
|
250
246
|
except Exception as e:
|
|
251
|
-
raise ValueError(f"SnapTrade API error: {
|
|
247
|
+
raise ValueError(f"SnapTrade API error: {e!s}")
|
|
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.
|
|
@@ -273,7 +267,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
273
267
|
>>> for security in securities:
|
|
274
268
|
... print(f"{security.ticker_symbol}: ${security.close_price}")
|
|
275
269
|
"""
|
|
276
|
-
|
|
270
|
+
_user_id, _user_secret = self._parse_access_token(access_token)
|
|
277
271
|
|
|
278
272
|
try:
|
|
279
273
|
# Get all holdings to extract securities
|
|
@@ -288,11 +282,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
288
282
|
return list(securities_map.values())
|
|
289
283
|
|
|
290
284
|
except Exception as e:
|
|
291
|
-
raise ValueError(f"SnapTrade API error: {
|
|
285
|
+
raise ValueError(f"SnapTrade API error: {e!s}")
|
|
292
286
|
|
|
293
|
-
async def get_investment_accounts(
|
|
294
|
-
self, access_token: str
|
|
295
|
-
) -> List[InvestmentAccount]:
|
|
287
|
+
async def get_investment_accounts(self, access_token: str) -> list[InvestmentAccount]:
|
|
296
288
|
"""Fetch investment accounts with aggregated holdings.
|
|
297
289
|
|
|
298
290
|
Returns accounts with total value, cost basis, and unrealized P&L.
|
|
@@ -328,9 +320,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
328
320
|
account_id = account["id"]
|
|
329
321
|
|
|
330
322
|
# Get positions for this account
|
|
331
|
-
positions_url =
|
|
332
|
-
f"{self.base_url}/accounts/{account_id}/positions"
|
|
333
|
-
)
|
|
323
|
+
positions_url = f"{self.base_url}/accounts/{account_id}/positions"
|
|
334
324
|
pos_response = await self.client.get(positions_url, headers=auth_headers)
|
|
335
325
|
pos_response.raise_for_status()
|
|
336
326
|
positions = await pos_response.json()
|
|
@@ -354,8 +344,8 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
354
344
|
type=account.get("type", "investment"),
|
|
355
345
|
subtype=account.get("account_type"),
|
|
356
346
|
balances={
|
|
357
|
-
"current":
|
|
358
|
-
"available":
|
|
347
|
+
"current": Decimal(str(balances.get("total", {}).get("amount", 0))),
|
|
348
|
+
"available": Decimal(str(balances.get("cash", {}).get("amount", 0))),
|
|
359
349
|
},
|
|
360
350
|
holdings=holdings,
|
|
361
351
|
)
|
|
@@ -366,11 +356,9 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
366
356
|
except httpx.HTTPStatusError as e:
|
|
367
357
|
raise self._transform_error(e)
|
|
368
358
|
except Exception as e:
|
|
369
|
-
raise ValueError(f"SnapTrade API error: {
|
|
359
|
+
raise ValueError(f"SnapTrade API error: {e!s}")
|
|
370
360
|
|
|
371
|
-
async def list_connections(
|
|
372
|
-
self, access_token: str
|
|
373
|
-
) -> List[Dict[str, Any]]:
|
|
361
|
+
async def list_connections(self, access_token: str) -> list[dict[str, Any]]:
|
|
374
362
|
"""List brokerage connections for a user.
|
|
375
363
|
|
|
376
364
|
Returns which brokerages the user has connected (E*TRADE, Robinhood, etc.).
|
|
@@ -393,14 +381,14 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
393
381
|
url = f"{self.base_url}/connections"
|
|
394
382
|
response = await self.client.get(url, headers=auth_headers)
|
|
395
383
|
response.raise_for_status()
|
|
396
|
-
return await response.json()
|
|
384
|
+
return cast("list[dict[str, Any]]", await response.json())
|
|
397
385
|
|
|
398
386
|
except httpx.HTTPStatusError as e:
|
|
399
387
|
raise self._transform_error(e)
|
|
400
388
|
except Exception as e:
|
|
401
|
-
raise ValueError(f"SnapTrade API error: {
|
|
389
|
+
raise ValueError(f"SnapTrade API error: {e!s}")
|
|
402
390
|
|
|
403
|
-
def get_brokerage_capabilities(self, brokerage_name: str) ->
|
|
391
|
+
def get_brokerage_capabilities(self, brokerage_name: str) -> dict[str, Any]:
|
|
404
392
|
"""Get capabilities for a specific brokerage.
|
|
405
393
|
|
|
406
394
|
Important: Robinhood is READ-ONLY (no trading support).
|
|
@@ -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,
|
|
@@ -528,11 +510,11 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
528
510
|
)
|
|
529
511
|
|
|
530
512
|
def _transform_transaction(
|
|
531
|
-
self, snaptrade_tx:
|
|
513
|
+
self, snaptrade_tx: dict[str, Any], account_id: str
|
|
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
|
@@ -20,7 +20,10 @@ if TYPE_CHECKING:
|
|
|
20
20
|
from fastapi import FastAPI
|
|
21
21
|
|
|
22
22
|
from ..providers.base import MarketDataProvider
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
# Deprecated: MarketDataClient alias for backward compatibility
|
|
25
|
+
# Use MarketDataProvider instead
|
|
26
|
+
MarketDataClient = MarketDataProvider # type: ignore[misc]
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def easy_market(
|
|
@@ -93,12 +96,12 @@ def easy_market(
|
|
|
93
96
|
|
|
94
97
|
else:
|
|
95
98
|
raise ValueError(
|
|
96
|
-
f"Unknown market data provider: {provider_name}.
|
|
99
|
+
f"Unknown market data provider: {provider_name}. Supported: alphavantage, yahoo"
|
|
97
100
|
)
|
|
98
101
|
|
|
99
102
|
|
|
100
103
|
def add_market_data(
|
|
101
|
-
app:
|
|
104
|
+
app: FastAPI,
|
|
102
105
|
*,
|
|
103
106
|
provider: str | MarketDataProvider | None = None,
|
|
104
107
|
prefix: str = "/market",
|
|
@@ -178,22 +181,21 @@ def add_market_data(
|
|
|
178
181
|
See Also:
|
|
179
182
|
- easy_market(): For standalone provider usage without FastAPI
|
|
180
183
|
- docs/market-data.md: API documentation and examples
|
|
181
|
-
- docs/adr/0004-market-data-integration.md: Architecture decisions
|
|
182
184
|
"""
|
|
183
185
|
from fastapi import HTTPException, Query
|
|
184
|
-
from typing import TYPE_CHECKING, Optional
|
|
185
186
|
|
|
186
187
|
# Import svc-infra public router (no auth required for market data)
|
|
187
188
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
188
189
|
|
|
189
|
-
if TYPE_CHECKING:
|
|
190
|
-
from fastapi import FastAPI
|
|
191
|
-
|
|
192
190
|
# Create market provider instance (or use the provided one)
|
|
193
191
|
if isinstance(provider, MarketDataProvider):
|
|
194
192
|
market = provider
|
|
195
193
|
else:
|
|
196
|
-
|
|
194
|
+
# Cast provider to Literal type for type checker
|
|
195
|
+
provider_literal: Literal["alphavantage", "yahoo"] | None = (
|
|
196
|
+
provider if provider in ("alphavantage", "yahoo", None) else None # type: ignore[assignment]
|
|
197
|
+
)
|
|
198
|
+
market = easy_market(provider=provider_literal, **config)
|
|
197
199
|
|
|
198
200
|
# Create router (public - no auth required)
|
|
199
201
|
router = public_router(prefix=prefix, tags=["Market Data"])
|
|
@@ -223,14 +225,17 @@ def add_market_data(
|
|
|
223
225
|
try:
|
|
224
226
|
candles = market.history(symbol, period=period, interval=interval)
|
|
225
227
|
# Convert to dicts if they're Pydantic models
|
|
226
|
-
candles_list = []
|
|
228
|
+
candles_list: list[dict] = []
|
|
227
229
|
for candle in candles:
|
|
228
230
|
if hasattr(candle, "model_dump"):
|
|
229
231
|
candles_list.append(candle.model_dump())
|
|
230
232
|
elif hasattr(candle, "dict"):
|
|
231
233
|
candles_list.append(candle.dict())
|
|
232
234
|
else:
|
|
233
|
-
|
|
235
|
+
# Cast to dict for type compatibility
|
|
236
|
+
candles_list.append(
|
|
237
|
+
dict(candle) if hasattr(candle, "__iter__") else {"data": candle}
|
|
238
|
+
)
|
|
234
239
|
return {"candles": candles_list}
|
|
235
240
|
except Exception as e:
|
|
236
241
|
raise HTTPException(status_code=400, detail=str(e))
|
fin_infra/models/__init__.py
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
from .accounts import Account, AccountType
|
|
2
|
-
from .transactions import Transaction
|
|
3
|
-
from .quotes import Quote
|
|
4
|
-
from .money import Money
|
|
5
|
-
from .candle import Candle
|
|
6
|
-
from .brokerage import Order, Position, PortfolioHistory
|
|
7
2
|
from .brokerage import Account as BrokerageAccount # Avoid name conflict
|
|
3
|
+
from .brokerage import Order, PortfolioHistory, Position
|
|
4
|
+
from .candle import Candle
|
|
5
|
+
from .money import Money
|
|
6
|
+
from .quotes import Quote
|
|
8
7
|
from .tax import (
|
|
8
|
+
CryptoTaxReport,
|
|
9
|
+
CryptoTransaction,
|
|
9
10
|
TaxDocument,
|
|
10
|
-
TaxFormW2,
|
|
11
|
-
TaxForm1099INT,
|
|
12
|
-
TaxForm1099DIV,
|
|
13
11
|
TaxForm1099B,
|
|
12
|
+
TaxForm1099DIV,
|
|
13
|
+
TaxForm1099INT,
|
|
14
14
|
TaxForm1099MISC,
|
|
15
|
-
|
|
16
|
-
CryptoTaxReport,
|
|
15
|
+
TaxFormW2,
|
|
17
16
|
TaxLiability,
|
|
18
17
|
)
|
|
18
|
+
from .transactions import Transaction
|
|
19
19
|
|
|
20
20
|
__all__ = [
|
|
21
21
|
"Account",
|
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/brokerage.py
CHANGED
fin_infra/models/candle.py
CHANGED
fin_infra/models/money.py
CHANGED
fin_infra/models/quotes.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
4
|
from decimal import Decimal
|
|
5
|
+
|
|
5
6
|
from pydantic import BaseModel, field_validator
|
|
6
7
|
|
|
7
8
|
|
|
@@ -16,5 +17,5 @@ class Quote(BaseModel):
|
|
|
16
17
|
def _ensure_tzaware(cls, v: datetime) -> datetime:
|
|
17
18
|
# Normalize to timezone-aware (UTC) for consistency
|
|
18
19
|
if v.tzinfo is None:
|
|
19
|
-
return v.replace(tzinfo=
|
|
20
|
-
return v.astimezone(
|
|
20
|
+
return v.replace(tzinfo=UTC)
|
|
21
|
+
return v.astimezone(UTC)
|
fin_infra/models/tax.py
CHANGED
fin_infra/models/transactions.py
CHANGED
|
@@ -2,24 +2,24 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from datetime import date
|
|
4
4
|
from decimal import Decimal
|
|
5
|
-
from typing import Optional
|
|
6
5
|
|
|
7
6
|
from pydantic import BaseModel, field_validator
|
|
8
7
|
|
|
9
8
|
|
|
10
9
|
class Transaction(BaseModel):
|
|
11
10
|
"""Financial transaction model.
|
|
12
|
-
|
|
11
|
+
|
|
13
12
|
Uses Decimal for amount to prevent floating-point precision errors
|
|
14
13
|
in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
|
|
15
14
|
"""
|
|
15
|
+
|
|
16
16
|
id: str
|
|
17
17
|
account_id: str
|
|
18
18
|
date: date
|
|
19
19
|
amount: Decimal
|
|
20
20
|
currency: str = "USD"
|
|
21
|
-
description:
|
|
22
|
-
category:
|
|
21
|
+
description: str | None = None
|
|
22
|
+
category: str | None = None
|
|
23
23
|
|
|
24
24
|
@field_validator("amount", mode="before")
|
|
25
25
|
@classmethod
|
fin_infra/net_worth/__init__.py
CHANGED
|
@@ -4,6 +4,13 @@ Net Worth Tracking Module
|
|
|
4
4
|
Calculates net worth by aggregating balances from multiple financial providers
|
|
5
5
|
(banking, brokerage, crypto) with historical snapshots and change detection.
|
|
6
6
|
|
|
7
|
+
**Feature Status**:
|
|
8
|
+
✅ STABLE: Core calculation (works with provided data)
|
|
9
|
+
✅ STABLE: Banking integration (Plaid, Teller)
|
|
10
|
+
⚠️ INTEGRATION: Brokerage integration (requires provider setup)
|
|
11
|
+
⚠️ INTEGRATION: Crypto integration (requires provider setup)
|
|
12
|
+
⚠️ INTEGRATION: Currency conversion (pass exchange_rate manually)
|
|
13
|
+
|
|
7
14
|
**Key Features**:
|
|
8
15
|
- Multi-provider aggregation (banking + brokerage + crypto)
|
|
9
16
|
- Currency normalization (all currencies → USD)
|
fin_infra/net_worth/add.py
CHANGED
|
@@ -36,13 +36,16 @@ tracker = add_net_worth_tracking(app)
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
from datetime import datetime, timedelta
|
|
39
|
+
from typing import Any
|
|
39
40
|
|
|
40
41
|
from fastapi import FastAPI, HTTPException, Query
|
|
41
42
|
|
|
42
43
|
from fin_infra.net_worth.ease import NetWorthTracker, easy_net_worth
|
|
43
44
|
from fin_infra.net_worth.models import (
|
|
45
|
+
AssetDetail,
|
|
44
46
|
ConversationResponse,
|
|
45
47
|
GoalProgressResponse,
|
|
48
|
+
LiabilityDetail,
|
|
46
49
|
NetWorthResponse,
|
|
47
50
|
SnapshotHistoryResponse,
|
|
48
51
|
)
|
|
@@ -188,8 +191,8 @@ def add_net_worth_tracking(
|
|
|
188
191
|
# Persistence: Asset/liability details stored in snapshot JSON fields or separate tables.
|
|
189
192
|
# Generate with: fin-infra scaffold net_worth --dest-dir app/models/
|
|
190
193
|
# For now, create empty lists for testing/examples.
|
|
191
|
-
asset_details = []
|
|
192
|
-
liability_details = []
|
|
194
|
+
asset_details: list[AssetDetail] = []
|
|
195
|
+
liability_details: list[LiabilityDetail] = []
|
|
193
196
|
|
|
194
197
|
# Calculate breakdowns
|
|
195
198
|
asset_allocation = calculate_asset_allocation(asset_details)
|
|
@@ -508,7 +511,7 @@ def add_net_worth_tracking(
|
|
|
508
511
|
snapshot = await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
|
|
509
512
|
|
|
510
513
|
# Get goals for context (if goal_tracker available)
|
|
511
|
-
goals = []
|
|
514
|
+
goals: list[Any] = []
|
|
512
515
|
if tracker.goal_tracker:
|
|
513
516
|
# TODO: Implement get_goals() method
|
|
514
517
|
pass
|
|
@@ -657,10 +660,10 @@ def add_net_worth_tracking(
|
|
|
657
660
|
|
|
658
661
|
try:
|
|
659
662
|
# Get current snapshot
|
|
660
|
-
|
|
663
|
+
await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
|
|
661
664
|
|
|
662
665
|
# Get historical snapshots for progress tracking
|
|
663
|
-
|
|
666
|
+
await tracker.get_snapshots(user_id=user_id, days=90)
|
|
664
667
|
|
|
665
668
|
# Persistence: Goal retrieval via scaffolded goals repository.
|
|
666
669
|
# Generate with: fin-infra scaffold goals --dest-dir app/models/
|
|
@@ -30,6 +30,7 @@ print(f"Net Worth: ${snapshot.total_net_worth:,.2f}")
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
import asyncio
|
|
33
|
+
import logging
|
|
33
34
|
import uuid
|
|
34
35
|
from datetime import datetime
|
|
35
36
|
from typing import Any
|
|
@@ -47,6 +48,8 @@ from fin_infra.net_worth.models import (
|
|
|
47
48
|
NetWorthSnapshot,
|
|
48
49
|
)
|
|
49
50
|
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
50
53
|
|
|
51
54
|
class NetWorthAggregator:
|
|
52
55
|
"""
|
|
@@ -213,16 +216,16 @@ class NetWorthAggregator:
|
|
|
213
216
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
214
217
|
|
|
215
218
|
# Aggregate results (skip failed providers)
|
|
216
|
-
all_assets = []
|
|
217
|
-
all_liabilities = []
|
|
218
|
-
actual_providers = []
|
|
219
|
+
all_assets: list[AssetDetail] = []
|
|
220
|
+
all_liabilities: list[LiabilityDetail] = []
|
|
221
|
+
actual_providers: list[str] = []
|
|
219
222
|
|
|
220
223
|
for i, result in enumerate(results):
|
|
221
|
-
if isinstance(result,
|
|
222
|
-
|
|
223
|
-
print(f"Provider {providers_used[i]} failed: {result}")
|
|
224
|
+
if isinstance(result, BaseException):
|
|
225
|
+
logger.warning("Provider %s failed: %s", providers_used[i], result)
|
|
224
226
|
continue
|
|
225
227
|
|
|
228
|
+
# result is now tuple[list[AssetDetail], list[LiabilityDetail]]
|
|
226
229
|
assets, liabilities = result
|
|
227
230
|
all_assets.extend(assets)
|
|
228
231
|
all_liabilities.extend(liabilities)
|
|
@@ -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)}. "
|