fin-infra 0.5.1__py3-none-any.whl → 0.7.0__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.
@@ -89,6 +89,15 @@ class CreateLinkTokenResponse(BaseModel):
89
89
  link_token: str
90
90
 
91
91
 
92
+ class CreateUpdateLinkTokenRequest(BaseModel):
93
+ """Request model for creating a link token in update mode (re-authentication)."""
94
+
95
+ user_id: str
96
+ access_token: str = Field(
97
+ ..., description="Existing access token for the item requiring re-auth"
98
+ )
99
+
100
+
92
101
  class ExchangeTokenRequest(BaseModel):
93
102
  """Request model for exchanging public token."""
94
103
 
@@ -334,6 +343,23 @@ def add_banking(
334
343
  link_token = banking.create_link_token(user_id=request.user_id)
335
344
  return CreateLinkTokenResponse(link_token=link_token)
336
345
 
346
+ @router.post("/link/update", response_model=CreateLinkTokenResponse)
347
+ async def create_update_link_token(request: CreateUpdateLinkTokenRequest):
348
+ """Create link token in update mode for re-authentication.
349
+
350
+ Use this endpoint when a user's bank connection has expired
351
+ (ITEM_LOGIN_REQUIRED error). The returned link token will open
352
+ Plaid Link in update mode, allowing the user to re-authenticate
353
+ without creating a new connection.
354
+
355
+ After successful re-authentication, the existing access_token
356
+ remains valid and no token exchange is needed.
357
+ """
358
+ link_token = banking.create_link_token(
359
+ user_id=request.user_id, access_token=request.access_token
360
+ )
361
+ return CreateLinkTokenResponse(link_token=link_token)
362
+
337
363
  @router.post("/exchange", response_model=ExchangeTokenResponse)
338
364
  async def exchange_token(request: ExchangeTokenRequest):
339
365
  """Exchange public token for access token (Plaid flow)."""
@@ -343,8 +369,29 @@ def add_banking(
343
369
  @router.get("/accounts")
344
370
  async def get_accounts(access_token: str = Depends(get_access_token)):
345
371
  """List accounts for access token."""
346
- accounts = banking.accounts(access_token=access_token)
347
- return {"accounts": accounts}
372
+ try:
373
+ accounts = banking.accounts(access_token=access_token)
374
+ return {"accounts": accounts}
375
+ except Exception as e:
376
+ error_str = str(e)
377
+ # Check for Plaid-specific errors that require user action
378
+ if "ITEM_LOGIN_REQUIRED" in error_str:
379
+ raise HTTPException(
380
+ status_code=401,
381
+ detail="ITEM_LOGIN_REQUIRED: Your bank connection has expired. Please re-authenticate your bank account.",
382
+ )
383
+ elif "INVALID_ACCESS_TOKEN" in error_str:
384
+ raise HTTPException(
385
+ status_code=401,
386
+ detail="INVALID_ACCESS_TOKEN: The access token is invalid or expired. Please reconnect your bank account.",
387
+ )
388
+ elif "ITEM_NOT_FOUND" in error_str:
389
+ raise HTTPException(
390
+ status_code=404,
391
+ detail="ITEM_NOT_FOUND: This bank connection no longer exists. Please reconnect your bank account.",
392
+ )
393
+ # Re-raise other errors
394
+ raise
348
395
 
349
396
  @router.get("/transactions")
350
397
  async def get_transactions(
@@ -108,7 +108,7 @@ class LLMCategorizer:
108
108
 
109
109
  Args:
110
110
  provider: LLM provider ("google_genai", "openai", "anthropic")
111
- model_name: Model name (e.g., "gemini-2.5-flash", "gpt-4.1-mini")
111
+ model_name: Model name (e.g., "gemini-2.5-flash", "gpt-5-mini")
112
112
  max_cost_per_day: Daily budget cap in USD (default $0.10)
113
113
  max_cost_per_month: Monthly budget cap in USD (default $2.00)
114
114
  cache_ttl: Cache TTL in seconds (default 24 hours)
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
16
16
  if TYPE_CHECKING:
17
17
  from fastapi import FastAPI
18
18
 
19
- from .aggregator import aggregate_insights, get_user_insights
19
+ from .aggregator import InsightTone, aggregate_insights, get_user_insights
20
20
  from .models import Insight, InsightCategory, InsightFeed, InsightPriority
21
21
 
22
22
  logger = logging.getLogger(__name__)
@@ -26,6 +26,7 @@ __all__ = [
26
26
  "InsightFeed",
27
27
  "InsightPriority",
28
28
  "InsightCategory",
29
+ "InsightTone",
29
30
  "aggregate_insights",
30
31
  "get_user_insights",
31
32
  "add_insights",
@@ -4,10 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
6
  from decimal import Decimal
7
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, Literal
8
8
 
9
9
  from .models import Insight, InsightCategory, InsightFeed, InsightPriority
10
10
 
11
+ # Tone type for insight text generation
12
+ InsightTone = Literal["professional", "fun"]
13
+
11
14
  if TYPE_CHECKING:
12
15
  from fin_infra.budgets.models import Budget
13
16
  from fin_infra.goals.models import Goal
@@ -23,6 +26,7 @@ def aggregate_insights(
23
26
  recurring_patterns: list[RecurringPattern] | None = None,
24
27
  portfolio_value: Decimal | None = None,
25
28
  tax_opportunities: list[dict] | None = None,
29
+ tone: InsightTone = "professional",
26
30
  ) -> InsightFeed:
27
31
  """
28
32
  Aggregate insights from multiple financial data sources.
@@ -35,6 +39,7 @@ def aggregate_insights(
35
39
  recurring_patterns: Detected recurring transactions
36
40
  portfolio_value: Current portfolio value
37
41
  tax_opportunities: Tax-loss harvesting or other tax insights
42
+ tone: Insight text tone - "professional" (formal) or "fun" (casual with emojis)
38
43
 
39
44
  Returns:
40
45
  InsightFeed with prioritized insights
@@ -44,6 +49,7 @@ def aggregate_insights(
44
49
  ... user_id="user_123",
45
50
  ... budgets=[budget1, budget2],
46
51
  ... goals=[goal1],
52
+ ... tone="fun",
47
53
  ... )
48
54
  >>> print(insights.critical_count)
49
55
  2
@@ -52,27 +58,27 @@ def aggregate_insights(
52
58
 
53
59
  # Net worth insights
54
60
  if net_worth_snapshots and len(net_worth_snapshots) >= 2:
55
- insights.extend(_generate_net_worth_insights(user_id, net_worth_snapshots))
61
+ insights.extend(_generate_net_worth_insights(user_id, net_worth_snapshots, tone))
56
62
 
57
63
  # Budget insights (critical if overspending)
58
64
  if budgets:
59
- insights.extend(_generate_budget_insights(user_id, budgets))
65
+ insights.extend(_generate_budget_insights(user_id, budgets, tone))
60
66
 
61
67
  # Goal insights
62
68
  if goals:
63
- insights.extend(_generate_goal_insights(user_id, goals))
69
+ insights.extend(_generate_goal_insights(user_id, goals, tone))
64
70
 
65
71
  # Recurring pattern insights
66
72
  if recurring_patterns:
67
- insights.extend(_generate_recurring_insights(user_id, recurring_patterns))
73
+ insights.extend(_generate_recurring_insights(user_id, recurring_patterns, tone))
68
74
 
69
75
  # Portfolio insights
70
76
  if portfolio_value:
71
- insights.extend(_generate_portfolio_insights(user_id, portfolio_value))
77
+ insights.extend(_generate_portfolio_insights(user_id, portfolio_value, tone))
72
78
 
73
79
  # Tax insights (high priority)
74
80
  if tax_opportunities:
75
- insights.extend(_generate_tax_insights(user_id, tax_opportunities))
81
+ insights.extend(_generate_tax_insights(user_id, tax_opportunities, tone))
76
82
 
77
83
  # Sort by priority: critical > high > medium > low
78
84
  priority_order = {
@@ -113,7 +119,9 @@ def get_user_insights(user_id: str, include_read: bool = False) -> InsightFeed:
113
119
  return InsightFeed(user_id=user_id, insights=[])
114
120
 
115
121
 
116
- def _generate_net_worth_insights(user_id: str, snapshots: list[NetWorthSnapshot]) -> list[Insight]:
122
+ def _generate_net_worth_insights(
123
+ user_id: str, snapshots: list[NetWorthSnapshot], tone: InsightTone
124
+ ) -> list[Insight]:
117
125
  """Generate insights from net worth trends."""
118
126
  insights = []
119
127
 
@@ -131,28 +139,42 @@ def _generate_net_worth_insights(user_id: str, snapshots: list[NetWorthSnapshot]
131
139
  )
132
140
 
133
141
  if change > 0:
142
+ if tone == "fun":
143
+ title = "Net Worth Glow Up! 📈"
144
+ desc = f"You're up ${change:,.2f} ({change_pct:.1f}%)! That's what we call winning 💪"
145
+ else:
146
+ title = "Net Worth Increased"
147
+ desc = f"Your net worth grew by ${change:,.2f} ({change_pct:.1f}%) this period"
134
148
  insights.append(
135
149
  Insight(
136
150
  id=f"nw_{user_id}_{datetime.now().timestamp()}",
137
151
  user_id=user_id,
138
152
  category=InsightCategory.NET_WORTH,
139
153
  priority=InsightPriority.MEDIUM,
140
- title="Net Worth Increased",
141
- description=f"Your net worth grew by ${change:,.2f} ({change_pct:.1f}%) this period",
154
+ title=title,
155
+ description=desc,
142
156
  value=change,
143
157
  )
144
158
  )
145
159
  elif change < 0:
146
160
  priority = InsightPriority.HIGH if abs(change_pct) > 10 else InsightPriority.MEDIUM
161
+ if tone == "fun":
162
+ title = "Net Worth Took a Hit 📉"
163
+ desc = f"Down ${abs(change):,.2f} ({abs(change_pct):.1f}%) - no stress, let's figure this out"
164
+ action = "Time to check what's up with your transactions 🔍"
165
+ else:
166
+ title = "Net Worth Decreased"
167
+ desc = f"Your net worth declined by ${abs(change):,.2f} ({abs(change_pct):.1f}%) this period"
168
+ action = "Review recent transactions and market changes"
147
169
  insights.append(
148
170
  Insight(
149
171
  id=f"nw_{user_id}_{datetime.now().timestamp()}",
150
172
  user_id=user_id,
151
173
  category=InsightCategory.NET_WORTH,
152
174
  priority=priority,
153
- title="Net Worth Decreased",
154
- description=f"Your net worth declined by ${abs(change):,.2f} ({abs(change_pct):.1f}%) this period",
155
- action="Review recent transactions and market changes",
175
+ title=title,
176
+ description=desc,
177
+ action=action,
156
178
  value=change,
157
179
  )
158
180
  )
@@ -160,7 +182,9 @@ def _generate_net_worth_insights(user_id: str, snapshots: list[NetWorthSnapshot]
160
182
  return insights
161
183
 
162
184
 
163
- def _generate_budget_insights(user_id: str, budgets: list[Budget]) -> list[Insight]:
185
+ def _generate_budget_insights(
186
+ user_id: str, budgets: list[Budget], tone: InsightTone
187
+ ) -> list[Insight]:
164
188
  """Generate insights from budget tracking."""
165
189
  insights: list[Insight] = []
166
190
 
@@ -174,7 +198,7 @@ def _generate_budget_insights(user_id: str, budgets: list[Budget]) -> list[Insig
174
198
  return insights
175
199
 
176
200
 
177
- def _generate_goal_insights(user_id: str, goals: list[Goal]) -> list[Insight]:
201
+ def _generate_goal_insights(user_id: str, goals: list[Goal], tone: InsightTone) -> list[Insight]:
178
202
  """Generate insights from goal progress."""
179
203
  insights = []
180
204
 
@@ -185,29 +209,45 @@ def _generate_goal_insights(user_id: str, goals: list[Goal]) -> list[Insight]:
185
209
 
186
210
  # Goal milestones
187
211
  if pct >= 100:
212
+ if tone == "fun":
213
+ title = f"🎉 '{goal.name}' Goal Crushed!"
214
+ desc = f"You hit ${target:,.2f}! Absolute legend 👑"
215
+ action = "Time to dream bigger - what's the next goal? 🚀"
216
+ else:
217
+ title = f"Goal '{goal.name}' Achieved!"
218
+ desc = f"You've reached your ${target:,.2f} goal"
219
+ action = "Consider setting a new goal or increasing this one"
188
220
  insights.append(
189
221
  Insight(
190
222
  id=f"goal_{goal.id}_{datetime.now().timestamp()}",
191
223
  user_id=user_id,
192
224
  category=InsightCategory.GOAL,
193
225
  priority=InsightPriority.HIGH,
194
- title=f"Goal '{goal.name}' Achieved!",
195
- description=f"You've reached your ${target:,.2f} goal",
196
- action="Consider setting a new goal or increasing this one",
226
+ title=title,
227
+ description=desc,
228
+ action=action,
197
229
  value=current,
198
230
  metadata={"goal_id": goal.id},
199
231
  )
200
232
  )
201
233
  elif pct >= 75:
234
+ if tone == "fun":
235
+ title = f"🔥 '{goal.name}' Almost There!"
236
+ desc = f"${current:,.2f} of ${target:,.2f} - you're {pct:.0f}% there, keep going!"
237
+ action = f"Just ${target - current:,.2f} more and you're golden ✨"
238
+ else:
239
+ title = f"Goal '{goal.name}' Almost There"
240
+ desc = f"${current:,.2f} of ${target:,.2f} saved ({pct:.0f}%)"
241
+ action = f"${target - current:,.2f} more to reach your goal"
202
242
  insights.append(
203
243
  Insight(
204
244
  id=f"goal_{goal.id}_{datetime.now().timestamp()}",
205
245
  user_id=user_id,
206
246
  category=InsightCategory.GOAL,
207
247
  priority=InsightPriority.MEDIUM,
208
- title=f"Goal '{goal.name}' Almost There",
209
- description=f"${current:,.2f} of ${target:,.2f} saved ({pct:.0f}%)",
210
- action=f"${target - current:,.2f} more to reach your goal",
248
+ title=title,
249
+ description=desc,
250
+ action=action,
211
251
  value=current,
212
252
  metadata={"goal_id": goal.id},
213
253
  )
@@ -216,7 +256,9 @@ def _generate_goal_insights(user_id: str, goals: list[Goal]) -> list[Insight]:
216
256
  return insights
217
257
 
218
258
 
219
- def _generate_recurring_insights(user_id: str, patterns: list[RecurringPattern]) -> list[Insight]:
259
+ def _generate_recurring_insights(
260
+ user_id: str, patterns: list[RecurringPattern], tone: InsightTone
261
+ ) -> list[Insight]:
220
262
  """Generate insights from recurring transactions."""
221
263
  insights = []
222
264
 
@@ -235,15 +277,23 @@ def _generate_recurring_insights(user_id: str, patterns: list[RecurringPattern])
235
277
  # Use average of range
236
278
  total += Decimal(str((p.amount_range[0] + p.amount_range[1]) / 2))
237
279
 
280
+ if tone == "fun":
281
+ title = "💸 Subscription Check!"
282
+ desc = f"{len(high_cost)} subscriptions over $50/mo = ${total:,.2f}. That's some serious recurring vibes"
283
+ action = "Time for a subscription audit? 🧐"
284
+ else:
285
+ title = "High-Cost Subscriptions Detected"
286
+ desc = f"You have {len(high_cost)} subscriptions over $50/month totaling ${total:,.2f}"
287
+ action = "Review if all subscriptions are still needed"
238
288
  insights.append(
239
289
  Insight(
240
290
  id=f"recurring_{user_id}_{datetime.now().timestamp()}",
241
291
  user_id=user_id,
242
292
  category=InsightCategory.RECURRING,
243
293
  priority=InsightPriority.MEDIUM,
244
- title="High-Cost Subscriptions Detected",
245
- description=f"You have {len(high_cost)} subscriptions over $50/month totaling ${total:,.2f}",
246
- action="Review if all subscriptions are still needed",
294
+ title=title,
295
+ description=desc,
296
+ action=action,
247
297
  value=total,
248
298
  )
249
299
  )
@@ -251,41 +301,52 @@ def _generate_recurring_insights(user_id: str, patterns: list[RecurringPattern])
251
301
  return insights
252
302
 
253
303
 
254
- def _generate_portfolio_insights(user_id: str, portfolio_value: Decimal) -> list[Insight]:
255
- """Generate insights from portfolio analysis."""
256
- insights = []
304
+ def _generate_portfolio_insights(
305
+ user_id: str, portfolio_value: Decimal, tone: InsightTone
306
+ ) -> list[Insight]:
307
+ """Generate insights from portfolio analysis.
257
308
 
258
- # Simple insight: portfolio exists
259
- if portfolio_value > 0:
260
- insights.append(
261
- Insight(
262
- id=f"portfolio_{user_id}_{datetime.now().timestamp()}",
263
- user_id=user_id,
264
- category=InsightCategory.PORTFOLIO,
265
- priority=InsightPriority.LOW,
266
- title="Portfolio Tracked",
267
- description=f"Your portfolio is valued at ${portfolio_value:,.2f}",
268
- value=portfolio_value,
269
- )
270
- )
309
+ Focus on actionable insights, not redundant value statements.
310
+ """
311
+ insights: list[Insight] = []
312
+
313
+ # Skip the basic "portfolio is valued at X" - that's shown in KPI cards
314
+ # Only generate insights when there's something actionable
315
+
316
+ # Example: Diversification check (would need account breakdown data)
317
+ # This is a placeholder - in production, pass account-level data
318
+
319
+ # Example: Rebalancing reminder based on market conditions
320
+ # This would integrate with market data APIs
271
321
 
272
322
  return insights
273
323
 
274
324
 
275
- def _generate_tax_insights(user_id: str, opportunities: list[dict]) -> list[Insight]:
325
+ def _generate_tax_insights(
326
+ user_id: str, opportunities: list[dict], tone: InsightTone
327
+ ) -> list[Insight]:
276
328
  """Generate insights from tax opportunities."""
277
329
  insights = []
278
330
 
279
331
  for opp in opportunities:
332
+ # Tax insights use the provided text, but we can add tone to default values
333
+ if tone == "fun":
334
+ default_title = "💰 Tax Savings Alert!"
335
+ default_desc = "Found a way to keep more of your money 🎉"
336
+ default_action = "Chat with a tax pro to lock this in 🔐"
337
+ else:
338
+ default_title = "Tax Opportunity"
339
+ default_desc = "Review this tax opportunity"
340
+ default_action = "Consult with tax professional"
280
341
  insights.append(
281
342
  Insight(
282
343
  id=f"tax_{user_id}_{datetime.now().timestamp()}",
283
344
  user_id=user_id,
284
345
  category=InsightCategory.TAX,
285
346
  priority=InsightPriority.HIGH,
286
- title=opp.get("title", "Tax Opportunity"),
287
- description=opp.get("description", "Review this tax opportunity"),
288
- action=opp.get("action", "Consult with tax professional"),
347
+ title=opp.get("title", default_title),
348
+ description=opp.get("description", default_desc),
349
+ action=opp.get("action", default_action),
289
350
  value=opp.get("value"),
290
351
  metadata=opp.get("metadata"),
291
352
  )
@@ -74,6 +74,7 @@ class Position(BaseModel):
74
74
  """Position model for current holdings."""
75
75
 
76
76
  symbol: str = Field(description="Trading symbol")
77
+ name: str | None = Field(None, description="Security name")
77
78
  qty: Decimal = Field(description="Total quantity held")
78
79
  side: Literal["long", "short"] = Field(description="Position side: long or short")
79
80
  avg_entry_price: Decimal = Field(description="Average entry price")
@@ -81,21 +81,41 @@ class PlaidClient(BankingProvider):
81
81
  api_client = plaid.ApiClient(configuration)
82
82
  self.client = plaid_api.PlaidApi(api_client)
83
83
 
84
- def create_link_token(self, user_id: str) -> str:
85
- request = LinkTokenCreateRequest(
86
- user=LinkTokenCreateRequestUser(client_user_id=user_id),
87
- client_name="fin-infra",
88
- products=[
84
+ def create_link_token(self, user_id: str, access_token: str | None = None) -> str:
85
+ """Create a Plaid Link token for new connections or re-authentication.
86
+
87
+ Args:
88
+ user_id: Client-defined user ID for the Link session
89
+ access_token: If provided, creates Link in update mode for re-authentication
90
+ (used when ITEM_LOGIN_REQUIRED error occurs)
91
+
92
+ Returns:
93
+ Link token string for Plaid Link initialization
94
+ """
95
+ # Build base request parameters
96
+ request_params = {
97
+ "user": LinkTokenCreateRequestUser(client_user_id=user_id),
98
+ "client_name": "fin-infra",
99
+ "country_codes": [CountryCode("US")],
100
+ "language": "en",
101
+ }
102
+
103
+ if access_token:
104
+ # Update mode: re-authenticate existing connection
105
+ # Don't include products - Plaid uses existing item's products
106
+ request_params["access_token"] = access_token
107
+ else:
108
+ # New connection: specify products to enable
109
+ request_params["products"] = [
89
110
  Products("auth"), # Account/routing numbers for ACH
90
111
  Products("transactions"), # Transaction history
91
112
  Products("liabilities"), # Credit cards, loans, student loans
92
113
  Products("investments"), # Brokerage, retirement accounts
93
114
  Products("assets"), # Asset reports for lending/verification
94
115
  Products("identity"), # Account holder info (name, email, phone)
95
- ],
96
- country_codes=[CountryCode("US")],
97
- language="en",
98
- )
116
+ ]
117
+
118
+ request = LinkTokenCreateRequest(**request_params)
99
119
  response = self.client.link_token_create(request)
100
120
  return cast("str", response["link_token"])
101
121
 
@@ -121,7 +121,7 @@ class TellerClient(BankingProvider):
121
121
  response.raise_for_status()
122
122
  return response.json()
123
123
 
124
- def create_link_token(self, user_id: str) -> str:
124
+ def create_link_token(self, user_id: str, access_token: str | None = None) -> str:
125
125
  """Create link token for user authentication.
126
126
 
127
127
  Note: Teller uses a simpler auth flow than Plaid. In production,
@@ -130,6 +130,9 @@ class TellerClient(BankingProvider):
130
130
 
131
131
  Args:
132
132
  user_id: Your application's user identifier
133
+ access_token: If provided, creates Link in update mode for re-authentication
134
+ (used when connection needs to be repaired). Teller handles
135
+ this differently than Plaid - see Teller Connect docs.
133
136
 
134
137
  Returns:
135
138
  Link token or enrollment ID for user to authenticate
@@ -138,13 +141,18 @@ class TellerClient(BankingProvider):
138
141
  httpx.HTTPStatusError: On HTTP errors
139
142
  """
140
143
  # Teller's enrollment endpoint for creating application links
144
+ payload: dict[str, Any] = {
145
+ "user_id": user_id,
146
+ "products": ["accounts", "transactions", "balances", "identity"],
147
+ }
148
+ # If access_token provided, add it for update mode (re-authentication)
149
+ if access_token:
150
+ payload["access_token"] = access_token
151
+
141
152
  response = self._request(
142
153
  "POST",
143
154
  "/enrollments",
144
- json={
145
- "user_id": user_id,
146
- "products": ["accounts", "transactions", "balances", "identity"],
147
- },
155
+ json=payload,
148
156
  )
149
157
  return cast("str", response.get("enrollment_id", ""))
150
158
 
@@ -70,8 +70,17 @@ class BankingProvider(ABC):
70
70
  """Abstract provider for bank account aggregation (Teller, Plaid, MX)."""
71
71
 
72
72
  @abstractmethod
73
- def create_link_token(self, user_id: str) -> str:
74
- """Create a link/connect token for user to authenticate with their bank."""
73
+ def create_link_token(self, user_id: str, access_token: str | None = None) -> str:
74
+ """Create a link/connect token for user to authenticate with their bank.
75
+
76
+ Args:
77
+ user_id: Client-defined user ID for the Link session
78
+ access_token: If provided, creates Link in update mode for re-authentication
79
+ (used when ITEM_LOGIN_REQUIRED error occurs)
80
+
81
+ Returns:
82
+ Link token string for initializing the bank connection UI
83
+ """
75
84
  pass
76
85
 
77
86
  @abstractmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fin-infra
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: Financial infrastructure toolkit: banking connections, market data, credit, cashflows, and brokerage integrations
5
5
  License: MIT
6
6
  Keywords: finance,banking,plaid,brokerage,markets,credit,tax,cashflow,fintech,infra
@@ -26,20 +26,26 @@ Provides-Extra: markets
26
26
  Provides-Extra: plaid
27
27
  Provides-Extra: yahoo
28
28
  Requires-Dist: ai-infra (>=0.1.142)
29
+ Requires-Dist: authlib (>=1.6.6)
29
30
  Requires-Dist: cashews[redis] (>=7.0)
30
31
  Requires-Dist: ccxt (>=4.0.0) ; extra == "markets" or extra == "crypto" or extra == "all"
31
32
  Requires-Dist: fastapi-users (>=15.0.2,<16.0.0)
33
+ Requires-Dist: filelock (>=3.20.3)
32
34
  Requires-Dist: httpx (>=0.25.0)
33
35
  Requires-Dist: langchain-core (>=1.2.5,<2.0.0)
34
36
  Requires-Dist: loguru (>=0.7.0)
35
37
  Requires-Dist: numpy (>=1.24.0)
36
38
  Requires-Dist: numpy-financial (>=1.0.0)
37
39
  Requires-Dist: plaid-python (>=25.0.0) ; extra == "plaid" or extra == "banking" or extra == "all"
40
+ Requires-Dist: protobuf (>=6.33.3)
38
41
  Requires-Dist: pydantic (>=2.0)
39
42
  Requires-Dist: pydantic-settings (>=2.0)
40
43
  Requires-Dist: python-dotenv (>=1.0.0)
41
44
  Requires-Dist: tenacity (>=8.0.0)
42
45
  Requires-Dist: typing-extensions (>=4.0)
46
+ Requires-Dist: urllib3 (>=2.6.3)
47
+ Requires-Dist: virtualenv (>=20.36.1)
48
+ Requires-Dist: werkzeug (>=3.1.5)
43
49
  Requires-Dist: yahooquery (>=2.3.0) ; extra == "markets" or extra == "yahoo" or extra == "all"
44
50
  Project-URL: Documentation, https://nfrax.com/fin-infra
45
51
  Project-URL: Homepage, https://github.com/nfraxlab/fin-infra
@@ -1,17 +1,19 @@
1
1
  fin_infra/__init__.py,sha256=7oL-CCsALNifBODAn9LriicaIrzgJkmVPvE-9duP0mw,1574
2
2
  fin_infra/__main__.py,sha256=1qNP7j0ffw0wFs1dBwDcJ9TNXlC6FcYuulzoV87pMi8,262
3
- fin_infra/analytics/__init__.py,sha256=-OsQUqLgOdafSNzbcIHy3G37nX2OwD0OzGj8C2Aiufs,2325
3
+ fin_infra/analytics/__init__.py,sha256=8RSyh2QieAZPWgnEBYzuG4pv97GgywwjiuAFcMu6Mtw,3006
4
4
  fin_infra/analytics/add.py,sha256=OdITXA5CjijhbBzokV3xlTcJCTiMLBDuUJ8OH9Pwkhw,12651
5
+ fin_infra/analytics/benchmark.py,sha256=YRtIamjh8HRvmvA5yPWWuJIzxyrdS0K-imV__DqVaTk,20356
5
6
  fin_infra/analytics/cash_flow.py,sha256=VSsQHwTi6r6VFoScH9nu9Fj8XHC4O4B9TkAI8Alobm4,10284
6
- fin_infra/analytics/ease.py,sha256=aTAPc-Lmh6XP7REPVlAom38MtKCqeS-auNW-rpXRZJs,14282
7
- fin_infra/analytics/models.py,sha256=XFHeVZoPE0PcSFj59dNsdxISFVwdrTLU4sDCtExumrg,8193
8
- fin_infra/analytics/portfolio.py,sha256=3xP34nQXvLdj_6Bpz5sufRoND5hpGDnmespWAixgh80,26405
7
+ fin_infra/analytics/ease.py,sha256=1xlr0TxJzGzCodktbCzb59tRG9jTLrcLQAxSWKFEs2I,15410
8
+ fin_infra/analytics/models.py,sha256=oebYQUI9-qIYKNyNSlr0P6BTO8lJJPUX0TeR6hFN_0s,8426
9
+ fin_infra/analytics/portfolio.py,sha256=pT6m19H8GVYD3gZvGPjTDtT9l3ieMxmx70Jx_Y6tmR8,29967
9
10
  fin_infra/analytics/projections.py,sha256=T7LLG0Pe5f-WwgfDITdTMk-l8cCLZTM9cEC_Vxf6mkc,9154
10
- fin_infra/analytics/rebalancing.py,sha256=VM8MgoJofmrCXPK1rbmVqWGB4FauNmHCL5HOEbrZR2g,14610
11
+ fin_infra/analytics/rebalancing.py,sha256=K4qbQa6p4CZ0T3j3mvZLadI45ilIA1rB1vtdlxlFWSc,16189
12
+ fin_infra/analytics/rebalancing_llm.py,sha256=gniB_tIahrYNZEodIZ02uXo6VLJQgq_sGzlA4glJRbg,26935
11
13
  fin_infra/analytics/savings.py,sha256=n3rGNFP8TU5mW-uz9kOuqX_mDiVnDyAeDN06Q7Abotw,7570
12
14
  fin_infra/analytics/scenarios.py,sha256=LE_dZVkbxxAx5sxitGhiOhZfWTlYtVbIvS9pEXkijLc,12246
13
15
  fin_infra/analytics/spending.py,sha256=ogLcfF5ZOLMkBIj02RISnA3hiY_PsLWZ2_AAzA7FenY,26209
14
- fin_infra/banking/__init__.py,sha256=6wwGNITzyehC9MBQc5jy0ewSTuNetyQu2AdND51O55w,22450
16
+ fin_infra/banking/__init__.py,sha256=Lt82vzUZUmekNi_abWz5ySvZlO64FVefd05RAxEd4gU,24537
15
17
  fin_infra/banking/history.py,sha256=YB-v9A03IZ_qki6A6mA-RO5Y4imlqk1CyP0W482ufdQ,10563
16
18
  fin_infra/banking/utils.py,sha256=HhxZbeaA8zqVttgMiJGnShTo_r_0DaD7T3IMq8n8340,15252
17
19
  fin_infra/brokerage/__init__.py,sha256=IXm5ko5T607LodexYSbKNZc6I_2CQGaOwQUlb-w9-ks,17137
@@ -33,7 +35,7 @@ fin_infra/categorization/__init__.py,sha256=p5uEFX0piPI5QmYtQ4lTrr0Cph5r01XpHDOi
33
35
  fin_infra/categorization/add.py,sha256=-I-V7R8vWj2-B8yjtFn_pNryQV5uascFR18a1Ltcqm0,6247
34
36
  fin_infra/categorization/ease.py,sha256=gvlVb_TL7kwm5oM_gWdrhZt4cJOSvYEgGipNJKK8CpM,5820
35
37
  fin_infra/categorization/engine.py,sha256=tZ-WTkPUUcKe6rDFoCSu5BF_gezcjJfx8Yn9eUX4lDQ,12114
36
- fin_infra/categorization/llm_layer.py,sha256=K3vn3Muf6VfsSDT-iRcKBJQyO-BSx8nfLQlK-UmBmG0,12824
38
+ fin_infra/categorization/llm_layer.py,sha256=7nFZf-S-JppGz6nXfvEmjr1-ouE1BR7fyiOYkUE6gnw,12822
37
39
  fin_infra/categorization/models.py,sha256=A8m3hAIbsUUV3eNjXpTiPWD1sYGjSdXEByTS3ZZASEA,5894
38
40
  fin_infra/categorization/rules.py,sha256=IpDnHBeuykRdu5vs3Lph4Y9-3RseIjjleQ5hZphQvNk,12849
39
41
  fin_infra/categorization/taxonomy.py,sha256=p-tSOwJ0O-rFZ1LIlHSYdaYdSc65084j0fdMI_6LW84,13251
@@ -76,8 +78,8 @@ fin_infra/goals/scaffold_templates/__init__.py,sha256=rLFam-mRsj8LvJu5kRBEIJtw9r
76
78
  fin_infra/goals/scaffold_templates/models.py.tmpl,sha256=b23Nlwm05MFMQE4qkrylTPXqulsN6cuFzNev2liY7DI,5714
77
79
  fin_infra/goals/scaffold_templates/repository.py.tmpl,sha256=4BFy-fPBR412p8wb8VzsekxM3uGno-odqZP_BuMAXBU,11046
78
80
  fin_infra/goals/scaffold_templates/schemas.py.tmpl,sha256=M1hS1pK9UDXcNqPW-NGu9804hTFe4FPdUDVgDSMcQl4,5331
79
- fin_infra/insights/__init__.py,sha256=fFYymoAY2zd7eooE-RqyFifXB_J-vVkHjyMak7o4wnQ,3984
80
- fin_infra/insights/aggregator.py,sha256=HtaJipSA-O_HVComBcyQdkGs6guoW81sYwRYHXGdBJI,10251
81
+ fin_infra/insights/__init__.py,sha256=zvyUNrs8Eyss0uip-yHMaRZo2afR9hO16r3mt5UWZY0,4016
82
+ fin_infra/insights/aggregator.py,sha256=a7fPc_nDb7OZ1rH0kPhdFL19vquoGqLhot7Kv3gRp2E,12762
81
83
  fin_infra/insights/models.py,sha256=xov_YV8oBLJt3YdyVjbryRfcXqmGeGiPvZsZHSbvtl8,3202
82
84
  fin_infra/investments/__init__.py,sha256=hJDHlKNXG0Hr3zsJeXdTEyvxL9PkVR8NkS2ULKnELWY,6727
83
85
  fin_infra/investments/add.py,sha256=UV2_99z1p8cUiifVFJXOF0lGd0ucMgZ5s9N7IFyE_NY,17193
@@ -95,7 +97,7 @@ fin_infra/investments/scaffold_templates/schemas.py.tmpl,sha256=knWmn-Kyr7AdgPD4
95
97
  fin_infra/markets/__init__.py,sha256=pVPtfOZxIeHsPuCDOCydmJjdsOc44R9B2d5EbCZNhRU,9863
96
98
  fin_infra/models/__init__.py,sha256=y94RJ_1-bzgNUCxqE76X56WIOk3-El_Jueqy7uB0rb8,860
97
99
  fin_infra/models/accounts.py,sha256=m_HdYHOe_m0GLnc_f5njo9n-zscWu-C0rJB6SAd5-aY,1098
98
- fin_infra/models/brokerage.py,sha256=TV5KMe78e-ttjcUbZIfdGo3x0NisAQ3puwv_ehtgSHc,8312
100
+ fin_infra/models/brokerage.py,sha256=0U--q2uEJrNankuC3zNMnQEp4Zej0l7ZE7Zq7Hum8X4,8376
99
101
  fin_infra/models/candle.py,sha256=5JeqUvqlFfYE61uViBeLKZCh2wKdJZH7Pvi-TLzN4FI,536
100
102
  fin_infra/models/credit.py,sha256=rSdSURsMe9_i2gxmwPTDwNQWOuM2zutL-OhvHsnbtmw,12144
101
103
  fin_infra/models/money.py,sha256=63pdGD1WBMHicJ1w7pbU1g5fqt4gIzPuqQQ2-NSlBuc,401
@@ -126,9 +128,9 @@ fin_infra/obs/__init__.py,sha256=kMMVl0fdwtJtZeKiusTuw0iO61Jo9-HNXsLmn3ffLRE,631
126
128
  fin_infra/obs/classifier.py,sha256=S7kSphgHN1O4GiMUdr3IjuXpoXU0XgGq132_U-njXX4,5153
127
129
  fin_infra/providers/__init__.py,sha256=jxhQm79T6DVXf7Wpy7luL-p50cE_IMUbjt4o3apzJQU,768
128
130
  fin_infra/providers/banking/base.py,sha256=KeNU4ur3zLKHVsBF1LQifcs2AKX06IEE-Rx_SetFeAs,102
129
- fin_infra/providers/banking/plaid_client.py,sha256=8Nvd9Ow_v6Scnw79R86uSvRcBRHPgc3ytsQze50E7aM,6524
130
- fin_infra/providers/banking/teller_client.py,sha256=733Eq-o9Yt7Sm_aWOeunJU4EWYRAaZNS7vgDVGqR0W4,10279
131
- fin_infra/providers/base.py,sha256=qKRv8C3TBP7r9J9gFxqeSU3Podh6w_eKI5iCPa15Ta8,8668
131
+ fin_infra/providers/banking/plaid_client.py,sha256=6yaXbPxg4QvebZAXyUFUthBIAN1EuXN59BnmTi69n0U,7374
132
+ fin_infra/providers/banking/teller_client.py,sha256=4chf7fQIybp-sgOWOiW9uJfMq31A9KMPcyMfn0Yi_IY,10752
133
+ fin_infra/providers/base.py,sha256=T8XoHTc3SY30XibfrS4eAMXTujgY8s2mJjBeKzscgDo,9037
132
134
  fin_infra/providers/brokerage/alpaca.py,sha256=BObiI_dFQZ3fOpTfmZMkri8sVrsz5uW6i5ZVUb0etCU,9923
133
135
  fin_infra/providers/brokerage/base.py,sha256=JJFH0Cqca4Rg4rmxfiwcQt-peRoBf4JpG3g6jx8DVks,106
134
136
  fin_infra/providers/credit/experian.py,sha256=r7lpFecgOdNEhb_Lxz2Z-BG8R3p2n0XlqDKL7y8NZ-0,482
@@ -174,8 +176,8 @@ fin_infra/utils/deprecation.py,sha256=DTcqv7ECnrWOOwoA07JOnRci4Hqqo9YtKSSmoS-DVP
174
176
  fin_infra/utils/http.py,sha256=rDEgYsEBrEe75ml5RA-iSs3xeU5W-3j-czJlT7WbrM4,632
175
177
  fin_infra/utils/retry.py,sha256=YiyTgy26eJ1ah7fE2_-ZPa4hv4bIT4OzjYolkNWb5j0,1057
176
178
  fin_infra/version.py,sha256=4t_crzhrLum--oyowUMxtjBTzUtWp7oRTF22ewEvJG4,49
177
- fin_infra-0.5.1.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
178
- fin_infra-0.5.1.dist-info/METADATA,sha256=u93UU3fU8TPtAPA7YcnL32rFqo7o8VIxQfVeZ6970aA,10842
179
- fin_infra-0.5.1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
180
- fin_infra-0.5.1.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
181
- fin_infra-0.5.1.dist-info/RECORD,,
179
+ fin_infra-0.7.0.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
180
+ fin_infra-0.7.0.dist-info/METADATA,sha256=v4dpkuG3id_UQEdZV4akwWdE4qTLWrcaK6-l2QaF0XQ,11050
181
+ fin_infra-0.7.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
182
+ fin_infra-0.7.0.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
183
+ fin_infra-0.7.0.dist-info/RECORD,,