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.
Files changed (126) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +30 -32
  4. fin_infra/analytics/cash_flow.py +6 -5
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/portfolio.py +19 -26
  7. fin_infra/analytics/projections.py +1 -3
  8. fin_infra/analytics/rebalancing.py +2 -4
  9. fin_infra/analytics/savings.py +1 -1
  10. fin_infra/analytics/spending.py +15 -11
  11. fin_infra/banking/__init__.py +33 -31
  12. fin_infra/banking/history.py +11 -12
  13. fin_infra/banking/utils.py +116 -110
  14. fin_infra/brokerage/__init__.py +27 -27
  15. fin_infra/budgets/__init__.py +3 -3
  16. fin_infra/budgets/add.py +16 -17
  17. fin_infra/budgets/alerts.py +3 -3
  18. fin_infra/budgets/tracker.py +4 -5
  19. fin_infra/cashflows/__init__.py +8 -10
  20. fin_infra/cashflows/core.py +1 -1
  21. fin_infra/categorization/__init__.py +1 -1
  22. fin_infra/categorization/add.py +17 -19
  23. fin_infra/categorization/ease.py +3 -4
  24. fin_infra/categorization/engine.py +21 -18
  25. fin_infra/categorization/llm_layer.py +10 -10
  26. fin_infra/categorization/models.py +1 -1
  27. fin_infra/categorization/rules.py +2 -4
  28. fin_infra/categorization/taxonomy.py +2 -2
  29. fin_infra/chat/__init__.py +13 -22
  30. fin_infra/chat/planning.py +57 -1
  31. fin_infra/cli/cmds/scaffold_cmds.py +11 -12
  32. fin_infra/clients/__init__.py +23 -1
  33. fin_infra/clients/base.py +1 -1
  34. fin_infra/clients/plaid.py +2 -2
  35. fin_infra/compliance/__init__.py +7 -6
  36. fin_infra/credit/add.py +7 -7
  37. fin_infra/credit/experian/auth.py +3 -2
  38. fin_infra/credit/experian/client.py +2 -2
  39. fin_infra/credit/experian/provider.py +19 -19
  40. fin_infra/crypto/__init__.py +8 -10
  41. fin_infra/crypto/insights.py +5 -6
  42. fin_infra/documents/add.py +11 -13
  43. fin_infra/documents/analysis.py +9 -9
  44. fin_infra/documents/ease.py +18 -17
  45. fin_infra/documents/models.py +7 -7
  46. fin_infra/documents/ocr.py +8 -8
  47. fin_infra/documents/storage.py +23 -14
  48. fin_infra/exceptions.py +1 -2
  49. fin_infra/goals/__init__.py +8 -8
  50. fin_infra/goals/add.py +36 -36
  51. fin_infra/goals/funding.py +4 -6
  52. fin_infra/goals/management.py +6 -7
  53. fin_infra/goals/milestones.py +2 -3
  54. fin_infra/goals/models.py +7 -11
  55. fin_infra/insights/__init__.py +12 -10
  56. fin_infra/insights/aggregator.py +1 -1
  57. fin_infra/investments/__init__.py +14 -9
  58. fin_infra/investments/add.py +53 -73
  59. fin_infra/investments/ease.py +16 -13
  60. fin_infra/investments/models.py +135 -69
  61. fin_infra/investments/providers/base.py +9 -15
  62. fin_infra/investments/providers/plaid.py +70 -55
  63. fin_infra/investments/providers/snaptrade.py +35 -53
  64. fin_infra/markets/__init__.py +16 -11
  65. fin_infra/models/__init__.py +10 -10
  66. fin_infra/models/accounts.py +2 -1
  67. fin_infra/models/brokerage.py +2 -1
  68. fin_infra/models/candle.py +1 -0
  69. fin_infra/models/money.py +1 -0
  70. fin_infra/models/quotes.py +4 -3
  71. fin_infra/models/tax.py +2 -1
  72. fin_infra/models/transactions.py +4 -4
  73. fin_infra/net_worth/__init__.py +7 -0
  74. fin_infra/net_worth/add.py +8 -5
  75. fin_infra/net_worth/aggregator.py +9 -6
  76. fin_infra/net_worth/calculator.py +8 -6
  77. fin_infra/net_worth/ease.py +36 -15
  78. fin_infra/net_worth/insights.py +4 -5
  79. fin_infra/net_worth/models.py +237 -116
  80. fin_infra/normalization/__init__.py +17 -15
  81. fin_infra/normalization/providers/exchangerate.py +5 -5
  82. fin_infra/obs/classifier.py +3 -3
  83. fin_infra/providers/banking/plaid_client.py +23 -22
  84. fin_infra/providers/banking/teller_client.py +14 -7
  85. fin_infra/providers/base.py +131 -14
  86. fin_infra/providers/brokerage/alpaca.py +7 -7
  87. fin_infra/providers/credit/experian.py +5 -0
  88. fin_infra/providers/market/alphavantage.py +6 -11
  89. fin_infra/providers/market/ccxt_crypto.py +25 -4
  90. fin_infra/providers/market/coingecko.py +5 -6
  91. fin_infra/providers/market/yahoo.py +23 -8
  92. fin_infra/providers/tax/__init__.py +1 -1
  93. fin_infra/providers/tax/irs.py +1 -1
  94. fin_infra/providers/tax/mock.py +8 -8
  95. fin_infra/providers/tax/taxbit.py +1 -1
  96. fin_infra/recurring/__init__.py +6 -6
  97. fin_infra/recurring/add.py +24 -12
  98. fin_infra/recurring/detector.py +8 -8
  99. fin_infra/recurring/detectors_llm.py +14 -13
  100. fin_infra/recurring/ease.py +3 -5
  101. fin_infra/recurring/insights.py +20 -19
  102. fin_infra/recurring/models.py +3 -3
  103. fin_infra/recurring/normalizer.py +3 -2
  104. fin_infra/recurring/normalizers.py +11 -10
  105. fin_infra/recurring/summary.py +13 -15
  106. fin_infra/scaffold/__init__.py +1 -1
  107. fin_infra/scaffold/budgets.py +9 -9
  108. fin_infra/scaffold/goals.py +5 -5
  109. fin_infra/security/__init__.py +8 -8
  110. fin_infra/security/encryption.py +6 -6
  111. fin_infra/security/models.py +7 -7
  112. fin_infra/security/pii_filter.py +6 -6
  113. fin_infra/security/pii_patterns.py +1 -1
  114. fin_infra/security/token_store.py +3 -1
  115. fin_infra/settings.py +2 -1
  116. fin_infra/tax/__init__.py +2 -2
  117. fin_infra/tax/add.py +3 -2
  118. fin_infra/tax/tlh.py +5 -5
  119. fin_infra/utils/http.py +5 -3
  120. fin_infra/utils/retry.py +2 -1
  121. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
  122. fin_infra-0.1.82.dist-info/RECORD +180 -0
  123. fin_infra-0.1.62.dist-info/RECORD +0 -180
  124. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  125. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  126. {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, Dict, List, Optional
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) -> Dict[str, 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: Optional[List[str]] = None,
121
- ) -> List[Holding]:
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: {str(e)}")
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: Optional[List[str]] = None,
186
- ) -> List[InvestmentTransaction]:
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(transactions_url, params=tx_params, headers=auth_headers)
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: {str(e)}")
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
- user_id, user_secret = self._parse_access_token(access_token)
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: {str(e)}")
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": float(balances.get("total", {}).get("amount", 0)),
358
- "available": float(balances.get("cash", {}).get("amount", 0)),
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: {str(e)}")
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: {str(e)}")
389
+ raise ValueError(f"SnapTrade API error: {e!s}")
402
390
 
403
- def get_brokerage_capabilities(self, brokerage_name: str) -> Dict[str, Any]:
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: Dict[str, Any], account_id: str
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", "")),
@@ -20,7 +20,10 @@ if TYPE_CHECKING:
20
20
  from fastapi import FastAPI
21
21
 
22
22
  from ..providers.base import MarketDataProvider
23
- from ..clients.base import MarketDataClient
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}. " f"Supported: alphavantage, yahoo"
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: "FastAPI",
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
- market = easy_market(provider=provider, **config)
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
- candles_list.append(candle)
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))
@@ -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
- CryptoTransaction,
16
- CryptoTaxReport,
15
+ TaxFormW2,
17
16
  TaxLiability,
18
17
  )
18
+ from .transactions import Transaction
19
19
 
20
20
  __all__ = [
21
21
  "Account",
@@ -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
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from decimal import Decimal
6
5
  from datetime import datetime
6
+ from decimal import Decimal
7
7
  from typing import Literal
8
+
8
9
  from pydantic import BaseModel, Field
9
10
 
10
11
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from decimal import Decimal
4
+
4
5
  from pydantic import BaseModel, field_validator
5
6
 
6
7
 
fin_infra/models/money.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from decimal import Decimal
4
+
4
5
  from pydantic import BaseModel, field_validator
5
6
 
6
7
 
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
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=timezone.utc)
20
- return v.astimezone(timezone.utc)
20
+ return v.replace(tzinfo=UTC)
21
+ return v.astimezone(UTC)
fin_infra/models/tax.py CHANGED
@@ -37,7 +37,8 @@ Example:
37
37
 
38
38
  from datetime import date, datetime
39
39
  from decimal import Decimal
40
- from pydantic import BaseModel, Field, ConfigDict
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field
41
42
 
42
43
 
43
44
  class TaxDocument(BaseModel):
@@ -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: Optional[str] = None
22
- category: Optional[str] = None
21
+ description: str | None = None
22
+ category: str | None = None
23
23
 
24
24
  @field_validator("amount", mode="before")
25
25
  @classmethod
@@ -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)
@@ -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
- snapshot = await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
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
- snapshots = await tracker.get_snapshots(user_id=user_id, days=90)
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, Exception):
222
- # Log error but continue (graceful degradation)
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((liability.name or liability.account_id, liability.currency, liability.balance))
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)}. "