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
@@ -36,16 +36,25 @@ Quick Start:
36
36
 
37
37
  from __future__ import annotations
38
38
 
39
- from typing import TYPE_CHECKING, List, Optional
39
+ from typing import TYPE_CHECKING
40
40
 
41
41
  try:
42
42
  from svc_infra.documents import (
43
43
  delete_document as base_delete_document,
44
+ )
45
+ from svc_infra.documents import (
44
46
  download_document as base_download_document,
47
+ )
48
+ from svc_infra.documents import (
45
49
  get_document as base_get_document,
50
+ )
51
+ from svc_infra.documents import (
46
52
  list_documents as base_list_documents,
53
+ )
54
+ from svc_infra.documents import (
47
55
  upload_document as base_upload_document,
48
56
  )
57
+
49
58
  HAS_SVC_INFRA_DOCUMENTS = True
50
59
  except ImportError:
51
60
  # Fallback for older svc-infra versions - use legacy implementation
@@ -63,15 +72,15 @@ if TYPE_CHECKING:
63
72
 
64
73
 
65
74
  async def upload_document(
66
- storage: "StorageBackend",
75
+ storage: StorageBackend,
67
76
  user_id: str,
68
77
  file: bytes,
69
- document_type: "DocumentType",
78
+ document_type: DocumentType,
70
79
  filename: str,
71
- metadata: Optional[dict] = None,
72
- tax_year: Optional[int] = None,
73
- form_type: Optional[str] = None,
74
- ) -> "FinancialDocument":
80
+ metadata: dict | None = None,
81
+ tax_year: int | None = None,
82
+ form_type: str | None = None,
83
+ ) -> FinancialDocument:
75
84
  """
76
85
  Upload a financial document (delegates to svc-infra, adds financial fields).
77
86
 
@@ -139,7 +148,7 @@ async def upload_document(
139
148
  return financial_doc
140
149
 
141
150
 
142
- def get_document(document_id: str) -> Optional["FinancialDocument"]:
151
+ def get_document(document_id: str) -> FinancialDocument | None:
143
152
  """
144
153
  Get financial document metadata by ID (delegates to svc-infra).
145
154
 
@@ -153,7 +162,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
153
162
  >>> doc = get_document("doc_abc123")
154
163
  >>> if doc:
155
164
  ... print(doc.filename, doc.type, doc.tax_year)
156
-
165
+
157
166
  Notes:
158
167
  - Delegates to svc-infra.documents.get_document
159
168
  - Converts base Document to FinancialDocument
@@ -186,7 +195,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
186
195
  return financial_doc
187
196
 
188
197
 
189
- async def download_document(storage: "StorageBackend", document_id: str) -> bytes:
198
+ async def download_document(storage: StorageBackend, document_id: str) -> bytes:
190
199
  """
191
200
  Download a financial document by ID (delegates to svc-infra).
192
201
 
@@ -212,7 +221,7 @@ async def download_document(storage: "StorageBackend", document_id: str) -> byte
212
221
  return await base_download_document(storage=storage, document_id=document_id)
213
222
 
214
223
 
215
- async def delete_document(storage: "StorageBackend", document_id: str) -> bool:
224
+ async def delete_document(storage: StorageBackend, document_id: str) -> bool:
216
225
  """
217
226
  Delete a financial document and its metadata (delegates to svc-infra).
218
227
 
@@ -237,11 +246,11 @@ async def delete_document(storage: "StorageBackend", document_id: str) -> bool:
237
246
 
238
247
  def list_documents(
239
248
  user_id: str,
240
- document_type: Optional["DocumentType"] = None,
241
- tax_year: Optional[int] = None,
249
+ document_type: DocumentType | None = None,
250
+ tax_year: int | None = None,
242
251
  limit: int = 100,
243
252
  offset: int = 0,
244
- ) -> List["FinancialDocument"]:
253
+ ) -> list[FinancialDocument]:
245
254
  """
246
255
  List user's financial documents with optional filters (delegates to svc-infra).
247
256
 
fin_infra/exceptions.py CHANGED
@@ -6,7 +6,7 @@ This module provides a consistent exception hierarchy across all fin-infra compo
6
6
  - Validation errors (data validation, compliance)
7
7
  - Calculation errors (financial calculations)
8
8
 
9
- All exceptions inherit from FinInfraError, allowing users to catch all library
9
+ All exceptions inherit from FinInfraError, allowing users to catch all library
10
10
  errors with a single except clause.
11
11
 
12
12
  Example:
@@ -23,7 +23,6 @@ from __future__ import annotations
23
23
  import logging
24
24
  from typing import Any
25
25
 
26
-
27
26
  # =============================================================================
28
27
  # Logging Helper
29
28
  # =============================================================================
@@ -5,6 +5,14 @@ Provides comprehensive goal management with milestone tracking,
5
5
  funding allocation, and progress monitoring.
6
6
  """
7
7
 
8
+ from fin_infra.goals.add import add_goals
9
+ from fin_infra.goals.funding import (
10
+ get_account_allocations,
11
+ get_goal_funding_sources,
12
+ link_account_to_goal,
13
+ remove_account_from_goal,
14
+ update_account_allocation,
15
+ )
8
16
  from fin_infra.goals.management import (
9
17
  FinancialGoalTracker,
10
18
  GoalProgressReport,
@@ -28,14 +36,6 @@ from fin_infra.goals.milestones import (
28
36
  get_next_milestone,
29
37
  trigger_milestone_notification,
30
38
  )
31
- from fin_infra.goals.funding import (
32
- link_account_to_goal,
33
- get_goal_funding_sources,
34
- get_account_allocations,
35
- update_account_allocation,
36
- remove_account_from_goal,
37
- )
38
- from fin_infra.goals.add import add_goals
39
39
  from fin_infra.goals.models import (
40
40
  FundingSource,
41
41
  Goal,
fin_infra/goals/add.py CHANGED
@@ -29,30 +29,30 @@ add_goals(app)
29
29
 
30
30
  import logging
31
31
  from datetime import datetime
32
- from typing import List, Optional
32
+ from typing import Any, cast
33
33
 
34
- from fastapi import FastAPI, HTTPException, status, Query, Body
34
+ from fastapi import Body, FastAPI, HTTPException, Query, status
35
35
  from pydantic import BaseModel, Field
36
36
 
37
+ from fin_infra.goals.funding import (
38
+ get_goal_funding_sources,
39
+ link_account_to_goal,
40
+ remove_account_from_goal,
41
+ update_account_allocation,
42
+ )
37
43
  from fin_infra.goals.management import (
38
44
  create_goal,
39
- list_goals,
40
- get_goal,
41
- update_goal,
42
45
  delete_goal,
46
+ get_goal,
43
47
  get_goal_progress,
48
+ list_goals,
49
+ update_goal,
44
50
  )
45
51
  from fin_infra.goals.milestones import (
46
52
  add_milestone,
47
53
  check_milestones,
48
54
  get_milestone_progress,
49
55
  )
50
- from fin_infra.goals.funding import (
51
- link_account_to_goal,
52
- get_goal_funding_sources,
53
- update_account_allocation,
54
- remove_account_from_goal,
55
- )
56
56
  from fin_infra.goals.models import GoalStatus
57
57
 
58
58
  logger = logging.getLogger(__name__)
@@ -63,7 +63,7 @@ logger = logging.getLogger(__name__)
63
63
  # ============================================================================
64
64
 
65
65
 
66
- def parse_iso_date(date_str: Optional[str]) -> Optional[datetime]:
66
+ def parse_iso_date(date_str: str | None) -> datetime | None:
67
67
  """Parse ISO date string to datetime object."""
68
68
  if date_str is None:
69
69
  return None
@@ -85,24 +85,24 @@ class CreateGoalRequest(BaseModel):
85
85
  name: str = Field(..., min_length=1, max_length=200, description="Goal name")
86
86
  goal_type: str = Field(..., description="Goal type (savings, debt, investment, etc.)")
87
87
  target_amount: float = Field(..., gt=0, description="Target amount")
88
- deadline: Optional[str] = Field(None, description="Deadline (ISO date)")
89
- description: Optional[str] = Field(None, description="Goal description")
90
- current_amount: Optional[float] = Field(0.0, ge=0, description="Current amount")
91
- auto_contribute: Optional[bool] = Field(False, description="Auto-contribute enabled")
92
- tags: Optional[List[str]] = Field(None, description="Goal tags")
88
+ deadline: str | None = Field(None, description="Deadline (ISO date)")
89
+ description: str | None = Field(None, description="Goal description")
90
+ current_amount: float | None = Field(0.0, ge=0, description="Current amount")
91
+ auto_contribute: bool | None = Field(False, description="Auto-contribute enabled")
92
+ tags: list[str] | None = Field(None, description="Goal tags")
93
93
 
94
94
 
95
95
  class UpdateGoalRequest(BaseModel):
96
96
  """Request body for updating a goal."""
97
97
 
98
- name: Optional[str] = Field(None, min_length=1, max_length=200)
99
- target_amount: Optional[float] = Field(None, gt=0)
100
- deadline: Optional[str] = None
101
- description: Optional[str] = None
102
- current_amount: Optional[float] = Field(None, ge=0)
103
- status: Optional[GoalStatus] = None
104
- auto_contribute: Optional[bool] = None
105
- tags: Optional[List[str]] = None
98
+ name: str | None = Field(None, min_length=1, max_length=200)
99
+ target_amount: float | None = Field(None, gt=0)
100
+ deadline: str | None = None
101
+ description: str | None = None
102
+ current_amount: float | None = Field(None, ge=0)
103
+ status: GoalStatus | None = None
104
+ auto_contribute: bool | None = None
105
+ tags: list[str] | None = None
106
106
 
107
107
 
108
108
  class AddMilestoneRequest(BaseModel):
@@ -110,7 +110,7 @@ class AddMilestoneRequest(BaseModel):
110
110
 
111
111
  amount: float = Field(..., gt=0, description="Milestone amount")
112
112
  description: str = Field(..., min_length=1, description="Milestone description")
113
- target_date: Optional[str] = Field(None, description="Target date (ISO date)")
113
+ target_date: str | None = Field(None, description="Target date (ISO date)")
114
114
 
115
115
 
116
116
  class LinkAccountRequest(BaseModel):
@@ -238,14 +238,14 @@ def add_goals(
238
238
  except ValueError as e:
239
239
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
240
240
 
241
- @router.get("", response_model=List[dict])
241
+ @router.get("", response_model=list[dict])
242
242
  async def list_goals_endpoint(
243
- user_id: Optional[str] = Query(
243
+ user_id: str | None = Query(
244
244
  None, description="User identifier (optional, returns all if not provided)"
245
245
  ),
246
- goal_type: Optional[str] = Query(None, description="Filter by goal type"),
247
- status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
248
- ) -> List[dict]:
246
+ goal_type: str | None = Query(None, description="Filter by goal type"),
247
+ status_filter: str | None = Query(None, alias="status", description="Filter by status"),
248
+ ) -> list[dict]:
249
249
  """
250
250
  List all goals for a user with optional filters.
251
251
 
@@ -442,8 +442,8 @@ def add_goals(
442
442
  except ValueError as e:
443
443
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
444
444
 
445
- @router.get("/{goal_id}/milestones", response_model=List[dict])
446
- async def list_milestones_endpoint(goal_id: str) -> List[dict]:
445
+ @router.get("/{goal_id}/milestones", response_model=list[dict])
446
+ async def list_milestones_endpoint(goal_id: str) -> list[dict]:
447
447
  """
448
448
  List all milestones for a goal.
449
449
 
@@ -469,7 +469,7 @@ def add_goals(
469
469
  # Get all milestones from the goal (check_milestones only returns newly reached ones)
470
470
  goal = get_goal(goal_id)
471
471
  milestones = goal.get("milestones", [])
472
- return milestones
472
+ return cast("list[dict[Any, Any]]", milestones)
473
473
  except KeyError:
474
474
  raise HTTPException(
475
475
  status_code=status.HTTP_404_NOT_FOUND, detail=f"Goal {goal_id} not found"
@@ -540,8 +540,8 @@ def add_goals(
540
540
  except ValueError as e:
541
541
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
542
542
 
543
- @router.get("/{goal_id}/funding", response_model=List[dict])
544
- async def list_funding_sources_endpoint(goal_id: str) -> List[dict]:
543
+ @router.get("/{goal_id}/funding", response_model=list[dict])
544
+ async def list_funding_sources_endpoint(goal_id: str) -> list[dict]:
545
545
  """
546
546
  List all funding sources for a goal.
547
547
 
@@ -22,14 +22,12 @@ Example:
22
22
  >>> # Raises ValueError if total allocation > 100%
23
23
  """
24
24
 
25
- from typing import Dict, List
26
-
27
- from fin_infra.goals.models import FundingSource
28
25
  from fin_infra.goals.management import get_goal
26
+ from fin_infra.goals.models import FundingSource
29
27
 
30
28
  # In-memory storage for funding allocations
31
29
  # Structure: {account_id: {goal_id: allocation_percent}}
32
- _FUNDING_STORE: Dict[str, Dict[str, float]] = {}
30
+ _FUNDING_STORE: dict[str, dict[str, float]] = {}
33
31
 
34
32
 
35
33
  def link_account_to_goal(
@@ -108,7 +106,7 @@ def link_account_to_goal(
108
106
  )
109
107
 
110
108
 
111
- def get_goal_funding_sources(goal_id: str) -> List[FundingSource]:
109
+ def get_goal_funding_sources(goal_id: str) -> list[FundingSource]:
112
110
  """
113
111
  Get all accounts funding a specific goal.
114
112
 
@@ -154,7 +152,7 @@ def get_goal_funding_sources(goal_id: str) -> List[FundingSource]:
154
152
  return funding_sources
155
153
 
156
154
 
157
- def get_account_allocations(account_id: str) -> Dict[str, float]:
155
+ def get_account_allocations(account_id: str) -> dict[str, float]:
158
156
  """
159
157
  Get all goal allocations for a specific account.
160
158
 
@@ -41,11 +41,10 @@ Example:
41
41
  """
42
42
 
43
43
  from datetime import datetime
44
- from typing import Any
44
+ from typing import Any, cast
45
45
 
46
46
  from pydantic import BaseModel, Field
47
47
 
48
-
49
48
  # ============================================================================
50
49
  # Pydantic Schemas (Structured Output)
51
50
  # ============================================================================
@@ -597,10 +596,10 @@ Goal type: {goal_type}
597
596
  Goal data: {goal}
598
597
 
599
598
  CALCULATED VALUES (use these exactly, don't recalculate):
600
- - Feasibility: {calc['feasibility']}
601
- - Required monthly: ${calc['required_monthly']:,.0f}
599
+ - Feasibility: {calc["feasibility"]}
600
+ - Required monthly: ${calc["required_monthly"]:,.0f}
602
601
  - Projected completion: {projected_date}
603
- - Current progress: {calc['current_progress']:.1%}
602
+ - Current progress: {calc["current_progress"]:.1%}
604
603
 
605
604
  Provide context and advice around these calculations. Suggest 2-3 alternative paths and 3-5 specific recommendations."""
606
605
 
@@ -839,7 +838,7 @@ def get_goal(goal_id: str) -> dict[str, Any]:
839
838
  if goal_id not in _GOALS_STORE:
840
839
  raise KeyError(f"Goal not found: {goal_id}")
841
840
 
842
- return _GOALS_STORE[goal_id]
841
+ return cast("dict[str, Any]", _GOALS_STORE[goal_id])
843
842
 
844
843
 
845
844
  def update_goal(
@@ -885,7 +884,7 @@ def update_goal(
885
884
 
886
885
  Goal(**goal) # Will raise ValidationError if invalid
887
886
 
888
- return goal
887
+ return cast("dict[str, Any]", goal)
889
888
 
890
889
 
891
890
  def delete_goal(goal_id: str) -> None:
@@ -26,12 +26,11 @@ Example:
26
26
  """
27
27
 
28
28
  from datetime import datetime
29
- from typing import Any
29
+ from typing import Any, cast
30
30
 
31
31
  from fin_infra.goals.management import get_goal, update_goal
32
32
  from fin_infra.goals.models import Milestone
33
33
 
34
-
35
34
  # ============================================================================
36
35
  # Milestone Management
37
36
  # ============================================================================
@@ -229,7 +228,7 @@ def get_next_milestone(goal_id: str) -> dict[str, Any] | None:
229
228
  # Find first unreached milestone (sorted by amount)
230
229
  for milestone in milestones:
231
230
  if not milestone.get("reached", False):
232
- return milestone
231
+ return cast("dict[str, Any]", milestone)
233
232
 
234
233
  return None
235
234
 
fin_infra/goals/models.py CHANGED
@@ -11,11 +11,9 @@ Provides comprehensive data models for:
11
11
 
12
12
  from datetime import datetime
13
13
  from enum import Enum
14
- from typing import Optional
15
14
 
16
15
  from pydantic import BaseModel, Field, field_validator
17
16
 
18
-
19
17
  # ============================================================================
20
18
  # Enums
21
19
  # ============================================================================
@@ -69,14 +67,14 @@ class Milestone(BaseModel):
69
67
  """
70
68
 
71
69
  amount: float = Field(..., description="Milestone target amount", gt=0)
72
- target_date: Optional[datetime] = Field(
70
+ target_date: datetime | None = Field(
73
71
  None, description="Target date to reach milestone (optional)"
74
72
  )
75
73
  description: str = Field(
76
74
  ..., description="Milestone description (e.g., '25% to emergency fund')", max_length=200
77
75
  )
78
76
  reached: bool = Field(default=False, description="Whether milestone has been reached")
79
- reached_date: Optional[datetime] = Field(
77
+ reached_date: datetime | None = Field(
80
78
  None, description="Date milestone was reached (if reached=True)"
81
79
  )
82
80
 
@@ -107,7 +105,7 @@ class FundingSource(BaseModel):
107
105
  ge=0.0,
108
106
  le=100.0,
109
107
  )
110
- account_name: Optional[str] = Field(
108
+ account_name: str | None = Field(
111
109
  None, description="Human-readable account name (e.g., 'Chase Savings')"
112
110
  )
113
111
 
@@ -154,9 +152,7 @@ class Goal(BaseModel):
154
152
  id: str = Field(..., description="Unique goal identifier")
155
153
  user_id: str = Field(..., description="User who owns this goal")
156
154
  name: str = Field(..., description="Goal name", max_length=200)
157
- description: Optional[str] = Field(
158
- None, description="Detailed goal description", max_length=1000
159
- )
155
+ description: str | None = Field(None, description="Detailed goal description", max_length=1000)
160
156
 
161
157
  # Goal type and status
162
158
  type: GoalType = Field(..., description="Goal type")
@@ -165,7 +161,7 @@ class Goal(BaseModel):
165
161
  # Financial targets
166
162
  target_amount: float = Field(..., description="Target amount to achieve", gt=0)
167
163
  current_amount: float = Field(default=0.0, description="Current progress toward target", ge=0.0)
168
- deadline: Optional[datetime] = Field(None, description="Target completion date")
164
+ deadline: datetime | None = Field(None, description="Target completion date")
169
165
 
170
166
  # Milestone tracking
171
167
  milestones: list[Milestone] = Field(default_factory=list, description="Progress milestones")
@@ -190,7 +186,7 @@ class Goal(BaseModel):
190
186
  updated_at: datetime = Field(
191
187
  default_factory=datetime.utcnow, description="Last update timestamp"
192
188
  )
193
- completed_at: Optional[datetime] = Field(
189
+ completed_at: datetime | None = Field(
194
190
  None, description="Completion timestamp (if status=COMPLETED)"
195
191
  )
196
192
 
@@ -264,7 +260,7 @@ class GoalProgress(BaseModel):
264
260
  )
265
261
 
266
262
  # Projections
267
- projected_completion_date: Optional[datetime] = Field(
263
+ projected_completion_date: datetime | None = Field(
268
264
  None, description="Projected completion date at current pace"
269
265
  )
270
266
  on_track: bool = Field(..., description="Whether on track to meet deadline")
@@ -10,8 +10,16 @@ Aggregates insights from multiple sources:
10
10
  - Cash flow projections
11
11
  """
12
12
 
13
- from .models import Insight, InsightFeed, InsightPriority, InsightCategory
13
+ import logging
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from fastapi import FastAPI
18
+
14
19
  from .aggregator import aggregate_insights, get_user_insights
20
+ from .models import Insight, InsightCategory, InsightFeed, InsightPriority
21
+
22
+ logger = logging.getLogger(__name__)
15
23
 
16
24
  __all__ = [
17
25
  "Insight",
@@ -25,7 +33,7 @@ __all__ = [
25
33
 
26
34
 
27
35
  def add_insights(
28
- app: "FastAPI", # type: ignore
36
+ app: "FastAPI",
29
37
  *,
30
38
  prefix: str = "/insights",
31
39
  ) -> None:
@@ -71,16 +79,11 @@ def add_insights(
71
79
  - Real-time aggregation from net worth, budgets, goals, etc.
72
80
  - Notification system for critical insights
73
81
  """
74
- from typing import TYPE_CHECKING
75
-
76
- if TYPE_CHECKING:
77
- from fastapi import FastAPI
78
-
79
82
  from fastapi import Query
83
+ from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
80
84
 
81
85
  # Import svc-infra user router (requires auth)
82
86
  from svc_infra.api.fastapi.dual.protected import user_router
83
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
84
87
 
85
88
  # Create router
86
89
  router = user_router(prefix=prefix, tags=["Insights"])
@@ -125,5 +128,4 @@ def add_insights(
125
128
  # Mount router
126
129
  app.include_router(router, include_in_schema=True)
127
130
 
128
- print(f"Insights feed enabled (unified financial insights)")
129
-
131
+ logger.info("Insights feed enabled")
@@ -224,7 +224,7 @@ def _generate_recurring_insights(user_id: str, patterns: list[RecurringPattern])
224
224
  high_cost = [
225
225
  p
226
226
  for p in patterns
227
- if p.amount is not None and p.amount > 50 or (p.amount_range and p.amount_range[1] > 50)
227
+ if (p.amount is not None and p.amount > 50) or (p.amount_range and p.amount_range[1] > 50)
228
228
  ]
229
229
  if high_cost:
230
230
  total = Decimal("0")
@@ -19,7 +19,7 @@ Example usage:
19
19
  # Explicit provider
20
20
  investments = easy_investments(provider="plaid")
21
21
  holdings = await investments.get_holdings(access_token)
22
-
22
+
23
23
  # Calculate metrics
24
24
  allocation = investments.calculate_allocation(holdings)
25
25
  metrics = investments.calculate_portfolio_metrics(holdings)
@@ -51,7 +51,8 @@ from typing import TYPE_CHECKING, Literal
51
51
  if TYPE_CHECKING:
52
52
  from fastapi import FastAPI
53
53
 
54
- from ..providers.base import InvestmentProvider
54
+ # Use the local InvestmentProvider base class (same as providers use)
55
+ from .providers.base import InvestmentProvider
55
56
 
56
57
  # Lazy imports to avoid loading provider SDKs unless needed
57
58
  _provider_cache: dict[str, InvestmentProvider] = {}
@@ -109,11 +110,12 @@ def easy_investments(
109
110
  provider = "plaid" # Default to Plaid
110
111
 
111
112
  # Check cache
112
- cache_key = f"{provider}:{str(sorted(config.items()))}"
113
+ cache_key = f"{provider}:{sorted(config.items())!s}"
113
114
  if cache_key in _provider_cache:
114
115
  return _provider_cache[cache_key]
115
116
 
116
117
  # Lazy import and initialize provider
118
+ instance: InvestmentProvider
117
119
  if provider == "plaid":
118
120
  from .providers.plaid import PlaidInvestmentProvider
119
121
 
@@ -123,9 +125,7 @@ def easy_investments(
123
125
 
124
126
  instance = SnapTradeInvestmentProvider(**config)
125
127
  else:
126
- raise ValueError(
127
- f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'"
128
- )
128
+ raise ValueError(f"Unknown provider: {provider}. Supported: 'plaid', 'snaptrade'")
129
129
 
130
130
  _provider_cache[cache_key] = instance
131
131
  return instance
@@ -172,14 +172,19 @@ def add_investments(
172
172
  >>> # GET /investments/transactions
173
173
  >>> # etc.
174
174
  """
175
- from .add import add_investments_impl
175
+ from .add import add_investments as add_investments_impl
176
+ from .providers.base import InvestmentProvider as InvestmentProviderBase
177
+
178
+ # Resolve provider from string Literal to actual InvestmentProvider instance
179
+ resolved_provider: InvestmentProviderBase | None = None
180
+ if provider is not None:
181
+ resolved_provider = easy_investments(provider=provider, **provider_config)
176
182
 
177
183
  return add_investments_impl(
178
184
  app,
179
- provider=provider,
185
+ provider=resolved_provider,
180
186
  prefix=prefix,
181
187
  tags=tags or ["Investments"],
182
- **provider_config,
183
188
  )
184
189
 
185
190