fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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 (83) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/cash_flow.py +6 -5
  3. fin_infra/analytics/portfolio.py +13 -20
  4. fin_infra/analytics/rebalancing.py +2 -4
  5. fin_infra/analytics/savings.py +1 -1
  6. fin_infra/analytics/spending.py +15 -11
  7. fin_infra/banking/__init__.py +8 -5
  8. fin_infra/banking/history.py +3 -3
  9. fin_infra/banking/utils.py +93 -88
  10. fin_infra/brokerage/__init__.py +5 -3
  11. fin_infra/budgets/tracker.py +2 -3
  12. fin_infra/cashflows/__init__.py +6 -8
  13. fin_infra/categorization/__init__.py +1 -1
  14. fin_infra/categorization/add.py +15 -16
  15. fin_infra/categorization/ease.py +3 -4
  16. fin_infra/categorization/engine.py +4 -4
  17. fin_infra/categorization/llm_layer.py +5 -6
  18. fin_infra/categorization/models.py +1 -1
  19. fin_infra/chat/__init__.py +7 -16
  20. fin_infra/chat/planning.py +57 -0
  21. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  22. fin_infra/compliance/__init__.py +3 -3
  23. fin_infra/credit/add.py +3 -2
  24. fin_infra/credit/experian/auth.py +3 -2
  25. fin_infra/credit/experian/client.py +2 -2
  26. fin_infra/credit/experian/provider.py +16 -16
  27. fin_infra/crypto/__init__.py +1 -1
  28. fin_infra/crypto/insights.py +1 -3
  29. fin_infra/documents/add.py +5 -5
  30. fin_infra/documents/ease.py +4 -3
  31. fin_infra/documents/models.py +3 -3
  32. fin_infra/documents/ocr.py +1 -1
  33. fin_infra/documents/storage.py +2 -1
  34. fin_infra/exceptions.py +1 -1
  35. fin_infra/goals/add.py +2 -2
  36. fin_infra/goals/management.py +6 -6
  37. fin_infra/goals/milestones.py +2 -2
  38. fin_infra/insights/__init__.py +7 -8
  39. fin_infra/investments/__init__.py +13 -8
  40. fin_infra/investments/add.py +39 -59
  41. fin_infra/investments/ease.py +16 -13
  42. fin_infra/investments/models.py +130 -64
  43. fin_infra/investments/providers/base.py +3 -8
  44. fin_infra/investments/providers/plaid.py +23 -34
  45. fin_infra/investments/providers/snaptrade.py +22 -40
  46. fin_infra/markets/__init__.py +11 -8
  47. fin_infra/models/accounts.py +2 -1
  48. fin_infra/models/transactions.py +3 -2
  49. fin_infra/net_worth/add.py +8 -5
  50. fin_infra/net_worth/aggregator.py +5 -4
  51. fin_infra/net_worth/calculator.py +8 -6
  52. fin_infra/net_worth/ease.py +36 -15
  53. fin_infra/net_worth/insights.py +4 -4
  54. fin_infra/net_worth/models.py +237 -116
  55. fin_infra/normalization/__init__.py +15 -13
  56. fin_infra/normalization/providers/exchangerate.py +3 -3
  57. fin_infra/obs/classifier.py +2 -2
  58. fin_infra/providers/banking/plaid_client.py +20 -19
  59. fin_infra/providers/banking/teller_client.py +13 -7
  60. fin_infra/providers/base.py +105 -13
  61. fin_infra/providers/brokerage/alpaca.py +7 -7
  62. fin_infra/providers/credit/experian.py +5 -0
  63. fin_infra/providers/market/ccxt_crypto.py +8 -3
  64. fin_infra/providers/tax/mock.py +3 -3
  65. fin_infra/recurring/add.py +20 -9
  66. fin_infra/recurring/detector.py +1 -1
  67. fin_infra/recurring/detectors_llm.py +10 -9
  68. fin_infra/recurring/ease.py +1 -1
  69. fin_infra/recurring/insights.py +9 -8
  70. fin_infra/recurring/models.py +3 -3
  71. fin_infra/recurring/normalizer.py +3 -2
  72. fin_infra/recurring/normalizers.py +9 -8
  73. fin_infra/scaffold/__init__.py +1 -1
  74. fin_infra/security/encryption.py +2 -2
  75. fin_infra/security/pii_patterns.py +1 -1
  76. fin_infra/security/token_store.py +3 -1
  77. fin_infra/tax/__init__.py +1 -1
  78. fin_infra/utils/http.py +3 -2
  79. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
  80. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
  81. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
  82. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
  83. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
@@ -208,12 +208,10 @@ def add_analytics(
208
208
  user_id: str,
209
209
  accounts: Optional[list[str]] = None,
210
210
  with_holdings: bool = Query(
211
- False,
212
- description="Use real holdings data from investment provider for accurate P/L"
211
+ False, description="Use real holdings data from investment provider for accurate P/L"
213
212
  ),
214
213
  access_token: Optional[str] = Query(
215
- None,
216
- description="Investment provider access token (required if with_holdings=true)"
214
+ None, description="Investment provider access token (required if with_holdings=true)"
217
215
  ),
218
216
  ) -> PortfolioMetrics:
219
217
  """
@@ -249,31 +247,31 @@ def add_analytics(
249
247
  if with_holdings:
250
248
  # Check if investment provider is available on app state
251
249
  investment_provider = getattr(app.state, "investment_provider", None)
252
-
250
+
253
251
  if investment_provider and access_token:
254
252
  try:
255
253
  # Fetch real holdings from investment provider
256
254
  from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
257
-
255
+
258
256
  holdings = await investment_provider.get_holdings(
259
257
  access_token=access_token,
260
258
  account_ids=accounts,
261
259
  )
262
-
260
+
263
261
  # Calculate metrics from real holdings
264
262
  return portfolio_metrics_with_holdings(holdings)
265
-
263
+
266
264
  except Exception as e:
267
265
  # Fall back to balance-only calculation on error
268
266
  # Log error but don't fail the request
269
267
  import logging
268
+
270
269
  logging.warning(f"Failed to fetch holdings, falling back to balance-only: {e}")
271
270
  elif with_holdings and not access_token:
272
271
  raise HTTPException(
273
- status_code=400,
274
- detail="access_token required when with_holdings=true"
272
+ status_code=400, detail="access_token required when with_holdings=true"
275
273
  )
276
-
274
+
277
275
  # Default: Use balance-only calculation (existing behavior)
278
276
  return await provider.portfolio_metrics(
279
277
  user_id,
@@ -6,6 +6,7 @@ Provides income vs expense analysis, breakdowns by source/category, and forecast
6
6
  from __future__ import annotations
7
7
 
8
8
  from datetime import datetime, timedelta
9
+ from decimal import Decimal
9
10
  from typing import Any, Optional
10
11
 
11
12
  from ..models import Transaction
@@ -219,7 +220,7 @@ async def forecast_cash_flow(
219
220
  def _categorize_transactions(
220
221
  transactions: list[Transaction],
221
222
  categorization_provider=None,
222
- ) -> tuple[dict[str, float], dict[str, float]]:
223
+ ) -> tuple[dict[str, Decimal], dict[str, Decimal]]:
223
224
  """Helper to categorize transactions into income sources and expense categories.
224
225
 
225
226
  Args:
@@ -229,19 +230,19 @@ def _categorize_transactions(
229
230
  Returns:
230
231
  Tuple of (income_by_source, expenses_by_category) dicts
231
232
  """
232
- income_by_source: dict[str, float] = {}
233
- expenses_by_category: dict[str, float] = {}
233
+ income_by_source: dict[str, Decimal] = {}
234
+ expenses_by_category: dict[str, Decimal] = {}
234
235
 
235
236
  for txn in transactions:
236
237
  if txn.amount > 0:
237
238
  # Income transaction
238
239
  source = _determine_income_source(txn)
239
- income_by_source[source] = income_by_source.get(source, 0) + txn.amount
240
+ income_by_source[source] = income_by_source.get(source, Decimal(0)) + txn.amount
240
241
  else:
241
242
  # Expense transaction
242
243
  category = _get_expense_category(txn, categorization_provider)
243
244
  amount = abs(txn.amount)
244
- expenses_by_category[category] = expenses_by_category.get(category, 0) + amount
245
+ expenses_by_category[category] = expenses_by_category.get(category, Decimal(0)) + amount
245
246
 
246
247
  return income_by_source, expenses_by_category
247
248
 
@@ -565,25 +565,18 @@ def portfolio_metrics_with_holdings(holdings: list) -> PortfolioMetrics:
565
565
  >>> metrics = portfolio_metrics_with_holdings(holdings)
566
566
  """
567
567
  # Import here to avoid circular dependency
568
- from decimal import Decimal
569
568
 
570
569
  # Calculate total portfolio value and cost basis
571
- total_value = float(sum(
572
- holding.institution_value
573
- for holding in holdings
574
- ))
570
+ total_value = float(sum(holding.institution_value for holding in holdings))
575
571
 
576
- total_cost_basis = float(sum(
577
- holding.cost_basis if holding.cost_basis is not None else 0
578
- for holding in holdings
579
- ))
572
+ total_cost_basis = float(
573
+ sum(holding.cost_basis if holding.cost_basis is not None else 0 for holding in holdings)
574
+ )
580
575
 
581
576
  # Calculate total return (P/L)
582
577
  total_return_dollars = total_value - total_cost_basis
583
578
  total_return_percent = (
584
- (total_return_dollars / total_cost_basis * 100.0)
585
- if total_cost_basis > 0
586
- else 0.0
579
+ (total_return_dollars / total_cost_basis * 100.0) if total_cost_basis > 0 else 0.0
587
580
  )
588
581
 
589
582
  # Calculate asset allocation from real security types
@@ -685,9 +678,7 @@ def calculate_day_change_with_snapshot(
685
678
  # Calculate day change
686
679
  day_change_dollars = current_total - previous_total
687
680
  day_change_percent = (
688
- (day_change_dollars / previous_total * 100.0)
689
- if previous_total > 0
690
- else 0.0
681
+ (day_change_dollars / previous_total * 100.0) if previous_total > 0 else 0.0
691
682
  )
692
683
 
693
684
  return {
@@ -739,18 +730,20 @@ def _calculate_allocation_from_holdings(
739
730
  }
740
731
 
741
732
  # Sum values by asset class
742
- allocation_values = defaultdict(float)
733
+ allocation_values: dict[str, float] = defaultdict(float)
743
734
  for holding in holdings:
744
- security_type = holding.security.type.value if hasattr(holding.security.type, 'value') else holding.security.type
735
+ security_type = (
736
+ holding.security.type.value
737
+ if hasattr(holding.security.type, "value")
738
+ else holding.security.type
739
+ )
745
740
  asset_class = type_to_class.get(security_type, "Other")
746
741
  allocation_values[asset_class] += float(holding.institution_value)
747
742
 
748
743
  # Convert to list of AssetAllocation objects
749
744
  allocation_list = [
750
745
  AssetAllocation(
751
- asset_class=asset_class,
752
- value=value,
753
- percentage=round((value / total_value) * 100.0, 2)
746
+ asset_class=asset_class, value=value, percentage=round((value / total_value) * 100.0, 2)
754
747
  )
755
748
  for asset_class, value in allocation_values.items()
756
749
  ]
@@ -329,13 +329,11 @@ def _generate_trade_reasoning(
329
329
 
330
330
  if action == "buy":
331
331
  return (
332
- f"Buy {symbol} to increase {asset_class} allocation "
333
- f"by {diff_pct:.1f}% towards target"
332
+ f"Buy {symbol} to increase {asset_class} allocation by {diff_pct:.1f}% towards target"
334
333
  )
335
334
  else:
336
335
  return (
337
- f"Sell {symbol} to decrease {asset_class} allocation "
338
- f"by {diff_pct:.1f}% towards target"
336
+ f"Sell {symbol} to decrease {asset_class} allocation by {diff_pct:.1f}% towards target"
339
337
  )
340
338
 
341
339
 
@@ -77,7 +77,7 @@ async def calculate_savings_rate(
77
77
  period_enum = Period(period)
78
78
  except ValueError:
79
79
  raise ValueError(
80
- f"Invalid period '{period}'. Must be one of: " f"{', '.join([p.value for p in Period])}"
80
+ f"Invalid period '{period}'. Must be one of: {', '.join([p.value for p in Period])}"
81
81
  )
82
82
 
83
83
  try:
@@ -162,12 +162,13 @@ async def analyze_spending(
162
162
  )
163
163
 
164
164
  return SpendingInsight(
165
- top_merchants=top_merchants,
166
- category_breakdown=dict(category_totals),
165
+ # Convert Decimal to float for model compatibility (intentional for Pydantic field types)
166
+ top_merchants=[(m, float(v)) for m, v in top_merchants],
167
+ category_breakdown={k: float(v) for k, v in category_totals.items()},
167
168
  spending_trends=spending_trends,
168
169
  anomalies=anomalies,
169
170
  period_days=days,
170
- total_spending=total_spending,
171
+ total_spending=float(total_spending) if total_spending else 0.0,
171
172
  )
172
173
 
173
174
 
@@ -291,10 +292,10 @@ async def _calculate_spending_trends(
291
292
  # In reality, would fetch historical data
292
293
  previous_amount = current_amount * Decimal("0.9")
293
294
 
294
- change_percent = (
295
- ((current_amount - previous_amount) / previous_amount) * 100
295
+ change_percent: float = (
296
+ float((current_amount - previous_amount) / previous_amount) * 100
296
297
  if previous_amount > 0
297
- else 0
298
+ else 0.0
298
299
  )
299
300
 
300
301
  # Threshold for "stable" is within 5%
@@ -342,8 +343,10 @@ async def _detect_spending_anomalies(
342
343
  # In reality, would calculate from historical data
343
344
  average_amount = current_amount * Decimal("0.8")
344
345
 
345
- deviation_percent = (
346
- ((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0
346
+ deviation_percent: float = (
347
+ float((current_amount - average_amount) / average_amount) * 100
348
+ if average_amount > 0
349
+ else 0.0
347
350
  )
348
351
 
349
352
  # Detect anomalies based on deviation
@@ -359,9 +362,10 @@ async def _detect_spending_anomalies(
359
362
  anomalies.append(
360
363
  SpendingAnomaly(
361
364
  category=category,
362
- current_amount=current_amount,
363
- average_amount=average_amount,
364
- deviation_percent=deviation_percent,
365
+ # Convert Decimal to float for model compatibility (intentional for Pydantic field types)
366
+ current_amount=float(current_amount),
367
+ average_amount=float(average_amount),
368
+ deviation_percent=float(deviation_percent),
365
369
  severity=severity,
366
370
  )
367
371
  )
@@ -45,7 +45,7 @@ from __future__ import annotations
45
45
 
46
46
  import os
47
47
  from datetime import date
48
- from typing import TYPE_CHECKING, Optional
48
+ from typing import TYPE_CHECKING, Optional, cast
49
49
 
50
50
  from pydantic import BaseModel, Field
51
51
 
@@ -199,7 +199,7 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
199
199
  }
200
200
 
201
201
  # Use provider registry to dynamically load and configure provider
202
- return resolve("banking", provider, **config)
202
+ return cast(BankingProvider, resolve("banking", provider, **config))
203
203
 
204
204
 
205
205
  def add_banking(
@@ -397,10 +397,13 @@ def add_banking(
397
397
  }
398
398
  """
399
399
  # Get all transactions from provider
400
+ # Convert date to ISO string format as expected by BankingProvider.transactions()
401
+ start_date_str: str | None = start_date.isoformat() if start_date else None
402
+ end_date_str: str | None = end_date.isoformat() if end_date else None
400
403
  transactions = banking.transactions(
401
404
  access_token=access_token,
402
- start_date=start_date,
403
- end_date=end_date,
405
+ start_date=start_date_str,
406
+ end_date=end_date_str,
404
407
  )
405
408
 
406
409
  # Apply filters
@@ -589,7 +592,7 @@ def add_banking(
589
592
 
590
593
 
591
594
  # Import utilities at end to avoid circular imports
592
- from .utils import (
595
+ from .utils import ( # noqa: E402
593
596
  validate_plaid_token,
594
597
  validate_teller_token,
595
598
  validate_mx_token,
@@ -68,10 +68,10 @@ def _check_in_memory_warning() -> None:
68
68
  global _production_warning_logged
69
69
  if _production_warning_logged:
70
70
  return
71
-
71
+
72
72
  env = os.getenv("ENV", "development").lower()
73
73
  storage_backend = os.getenv("FIN_INFRA_STORAGE_BACKEND", "memory").lower()
74
-
74
+
75
75
  if env in ("production", "staging") and storage_backend == "memory":
76
76
  _logger.warning(
77
77
  "⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
@@ -135,7 +135,7 @@ def record_balance_snapshot(
135
135
  """
136
136
  # Check if in-memory storage is being used in production
137
137
  _check_in_memory_warning()
138
-
138
+
139
139
  snapshot = BalanceSnapshot(
140
140
  account_id=account_id,
141
141
  balance=balance,