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
@@ -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
 
@@ -181,19 +181,19 @@ def add_market_data(
181
181
  - docs/adr/0004-market-data-integration.md: Architecture decisions
182
182
  """
183
183
  from fastapi import HTTPException, Query
184
- from typing import TYPE_CHECKING, Optional
185
184
 
186
185
  # Import svc-infra public router (no auth required for market data)
187
186
  from svc_infra.api.fastapi.dual.public import public_router
188
187
 
189
- if TYPE_CHECKING:
190
- from fastapi import FastAPI
191
-
192
188
  # Create market provider instance (or use the provided one)
193
189
  if isinstance(provider, MarketDataProvider):
194
190
  market = provider
195
191
  else:
196
- market = easy_market(provider=provider, **config)
192
+ # Cast provider to Literal type for type checker
193
+ provider_literal: Literal["alphavantage", "yahoo"] | None = (
194
+ provider if provider in ("alphavantage", "yahoo", None) else None # type: ignore[assignment]
195
+ )
196
+ market = easy_market(provider=provider_literal, **config)
197
197
 
198
198
  # Create router (public - no auth required)
199
199
  router = public_router(prefix=prefix, tags=["Market Data"])
@@ -223,14 +223,17 @@ def add_market_data(
223
223
  try:
224
224
  candles = market.history(symbol, period=period, interval=interval)
225
225
  # Convert to dicts if they're Pydantic models
226
- candles_list = []
226
+ candles_list: list[dict] = []
227
227
  for candle in candles:
228
228
  if hasattr(candle, "model_dump"):
229
229
  candles_list.append(candle.model_dump())
230
230
  elif hasattr(candle, "dict"):
231
231
  candles_list.append(candle.dict())
232
232
  else:
233
- candles_list.append(candle)
233
+ # Cast to dict for type compatibility
234
+ candles_list.append(
235
+ dict(candle) if hasattr(candle, "__iter__") else {"data": candle}
236
+ )
234
237
  return {"candles": candles_list}
235
238
  except Exception as e:
236
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
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import date
3
+ from datetime import date # noqa: F401 - used in type annotation
4
4
  from decimal import Decimal
5
5
  from typing import Optional
6
6
 
@@ -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
@@ -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/
@@ -213,16 +213,17 @@ class NetWorthAggregator:
213
213
  results = await asyncio.gather(*tasks, return_exceptions=True)
214
214
 
215
215
  # Aggregate results (skip failed providers)
216
- all_assets = []
217
- all_liabilities = []
218
- actual_providers = []
216
+ all_assets: list[AssetDetail] = []
217
+ all_liabilities: list[LiabilityDetail] = []
218
+ actual_providers: list[str] = []
219
219
 
220
220
  for i, result in enumerate(results):
221
- if isinstance(result, Exception):
221
+ if isinstance(result, BaseException):
222
222
  # Log error but continue (graceful degradation)
223
223
  print(f"Provider {providers_used[i]} failed: {result}")
224
224
  continue
225
225
 
226
+ # result is now tuple[list[AssetDetail], list[LiabilityDetail]]
226
227
  assets, liabilities = result
227
228
  all_assets.extend(assets)
228
229
  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)}. "
@@ -121,6 +121,15 @@ class NetWorthTracker:
121
121
  self.goal_tracker = goal_tracker
122
122
  self.conversation = conversation
123
123
 
124
+ # Configuration set by easy_net_worth(); declared here for type checkers.
125
+ self.snapshot_schedule: str = "daily"
126
+ self.change_threshold_percent: float = 5.0
127
+ self.change_threshold_amount: float = 10000.0
128
+ self.enable_llm: bool = False
129
+ self.llm_provider: str | None = None
130
+ self.llm_model: str | None = None
131
+ self.config: dict[str, Any] = {}
132
+
124
133
  async def calculate_net_worth(
125
134
  self,
126
135
  user_id: str,
@@ -368,12 +377,20 @@ def easy_net_worth(
368
377
 
369
378
  if enable_llm:
370
379
  try:
371
- from ai_infra.llm import LLM
380
+ from ai_infra.llm.llm import LLM
372
381
  except ImportError:
373
382
  raise ImportError(
374
- "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"
375
384
  )
376
385
 
386
+ cache = None
387
+ try:
388
+ from svc_infra.cache import get_cache
389
+
390
+ cache = get_cache()
391
+ except Exception:
392
+ cache = None
393
+
377
394
  # Determine default model
378
395
  default_models = {
379
396
  "google": "gemini-2.0-flash-exp",
@@ -384,7 +401,7 @@ def easy_net_worth(
384
401
 
385
402
  if not model_name:
386
403
  raise ValueError(
387
- f"Unknown llm_provider: {llm_provider}. " f"Use 'google', 'openai', or 'anthropic'"
404
+ f"Unknown llm_provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'"
388
405
  )
389
406
 
390
407
  # Create shared LLM instance
@@ -416,18 +433,22 @@ def easy_net_worth(
416
433
  # goals.management not yet implemented, skip
417
434
  pass
418
435
 
419
- try:
420
- from fin_infra.conversation import FinancialPlanningConversation
421
-
422
- conversation = FinancialPlanningConversation(
423
- llm=llm,
424
- cache=cache, # Required for context storage
425
- provider=llm_provider,
426
- model_name=model_name,
427
- )
428
- except ImportError:
429
- # conversation module not yet implemented, skip
430
- pass
436
+ if cache is not None:
437
+ try:
438
+ from fin_infra.conversation import FinancialPlanningConversation
439
+
440
+ conversation = FinancialPlanningConversation(
441
+ llm=llm,
442
+ cache=cache, # Required for context storage
443
+ provider=llm_provider,
444
+ model_name=model_name,
445
+ )
446
+ except ImportError:
447
+ # conversation module not yet implemented, skip
448
+ pass
449
+ except Exception:
450
+ # Cache not configured or other runtime issue; skip optional conversation wiring.
451
+ pass
431
452
 
432
453
  # Create tracker
433
454
  tracker = NetWorthTracker(
@@ -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