fin-infra 0.1.66__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.
Files changed (50) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/portfolio.py +12 -18
  3. fin_infra/analytics/rebalancing.py +2 -4
  4. fin_infra/analytics/savings.py +1 -1
  5. fin_infra/analytics/spending.py +3 -1
  6. fin_infra/banking/history.py +3 -3
  7. fin_infra/banking/utils.py +88 -82
  8. fin_infra/brokerage/__init__.py +1 -1
  9. fin_infra/budgets/tracker.py +2 -3
  10. fin_infra/categorization/ease.py +2 -3
  11. fin_infra/categorization/llm_layer.py +2 -2
  12. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  13. fin_infra/credit/experian/provider.py +14 -14
  14. fin_infra/crypto/__init__.py +1 -1
  15. fin_infra/documents/add.py +4 -4
  16. fin_infra/documents/ease.py +4 -3
  17. fin_infra/documents/models.py +3 -3
  18. fin_infra/documents/ocr.py +1 -1
  19. fin_infra/documents/storage.py +2 -1
  20. fin_infra/exceptions.py +1 -1
  21. fin_infra/goals/management.py +3 -3
  22. fin_infra/insights/__init__.py +0 -1
  23. fin_infra/investments/__init__.py +2 -4
  24. fin_infra/investments/add.py +37 -56
  25. fin_infra/investments/ease.py +7 -8
  26. fin_infra/investments/models.py +29 -17
  27. fin_infra/investments/providers/base.py +3 -8
  28. fin_infra/investments/providers/plaid.py +19 -29
  29. fin_infra/investments/providers/snaptrade.py +18 -36
  30. fin_infra/markets/__init__.py +4 -2
  31. fin_infra/models/accounts.py +2 -1
  32. fin_infra/models/transactions.py +2 -1
  33. fin_infra/net_worth/calculator.py +8 -6
  34. fin_infra/net_worth/ease.py +2 -2
  35. fin_infra/net_worth/insights.py +4 -4
  36. fin_infra/normalization/__init__.py +3 -1
  37. fin_infra/providers/banking/plaid_client.py +16 -16
  38. fin_infra/providers/base.py +5 -5
  39. fin_infra/providers/brokerage/alpaca.py +2 -2
  40. fin_infra/providers/market/ccxt_crypto.py +4 -1
  41. fin_infra/recurring/add.py +3 -1
  42. fin_infra/recurring/detector.py +1 -1
  43. fin_infra/recurring/normalizer.py +1 -1
  44. fin_infra/scaffold/__init__.py +1 -1
  45. fin_infra/tax/__init__.py +1 -1
  46. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/METADATA +1 -1
  47. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/RECORD +50 -50
  48. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/LICENSE +0 -0
  49. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/WHEEL +0 -0
  50. {fin_infra-0.1.66.dist-info → fin_infra-0.1.68.dist-info}/entry_points.txt +0 -0
@@ -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(None, description="Sector classification (Technology, Healthcare)")
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(..., ge=0, description="Current market value (quantity × price)")
197
- cost_basis: Optional[Decimal] = Field(None, ge=0, description="Total cost basis (original purchase price)")
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(..., alias="type", description="Transaction type (buy, sell, dividend)")
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": {"ticker_symbol": "AAPL", "name": "Apple Inc.", "type": "equity"},
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
- self.client.investments_transactions_get(request)
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("name", account_dict.get("official_name", "Unknown Account")),
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(str(account_dict.get("balances", {}).get("available") or 0)),
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"))) if plaid_holding.get("cost_basis") else None,
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(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
 
@@ -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", "")),
@@ -93,7 +93,7 @@ def easy_market(
93
93
 
94
94
  else:
95
95
  raise ValueError(
96
- f"Unknown market data provider: {provider_name}. " f"Supported: alphavantage, yahoo"
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(dict(candle) if hasattr(candle, "__iter__") else {"data": candle})
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))
@@ -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
@@ -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((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)}. "
@@ -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. " "Install with: pip install ai-infra"
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}. " f"Use 'google', 'openai', or 'anthropic'"
404
+ f"Unknown llm_provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'"
405
405
  )
406
406
 
407
407
  # Create shared LLM instance
@@ -479,13 +479,13 @@ class NetWorthInsightsGenerator:
479
479
 
480
480
  user_prompt = f"""Analyze wealth trends:
481
481
 
482
- Current net worth: ${current['total_net_worth']:,.0f}
483
- Previous net worth: ${previous['total_net_worth']:,.0f}
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['total_assets']:,.0f}
488
- Liabilities: ${current['total_liabilities']:,.0f}
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(..., alias="from", description="Source currency code (e.g., USD)"),
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."""