fin-infra 0.6.0__py3-none-any.whl → 0.8.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.
- fin_infra/analytics/__init__.py +24 -0
- fin_infra/analytics/benchmark.py +594 -0
- fin_infra/analytics/ease.py +33 -2
- fin_infra/analytics/models.py +3 -0
- fin_infra/analytics/portfolio.py +113 -23
- fin_infra/analytics/rebalancing.py +50 -4
- fin_infra/analytics/rebalancing_llm.py +710 -0
- fin_infra/banking/__init__.py +4 -0
- fin_infra/categorization/llm_layer.py +1 -1
- fin_infra/insights/__init__.py +2 -1
- fin_infra/insights/aggregator.py +106 -45
- fin_infra/models/brokerage.py +1 -0
- fin_infra/providers/banking/teller_client.py +43 -8
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/METADATA +7 -1
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/RECORD +18 -16
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/entry_points.txt +0 -0
fin_infra/banking/__init__.py
CHANGED
|
@@ -28,6 +28,8 @@ Environment Variables:
|
|
|
28
28
|
Teller:
|
|
29
29
|
TELLER_CERTIFICATE_PATH: Path to certificate.pem file
|
|
30
30
|
TELLER_PRIVATE_KEY_PATH: Path to private_key.pem file
|
|
31
|
+
TELLER_CERTIFICATE: Inline certificate PEM content (alternative to path)
|
|
32
|
+
TELLER_PRIVATE_KEY: Inline private key PEM content (alternative to path)
|
|
31
33
|
TELLER_ENVIRONMENT: "sandbox" or "production" (default: sandbox)
|
|
32
34
|
|
|
33
35
|
Plaid:
|
|
@@ -191,6 +193,8 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
|
|
|
191
193
|
config = {
|
|
192
194
|
"cert_path": os.getenv("TELLER_CERTIFICATE_PATH"),
|
|
193
195
|
"key_path": os.getenv("TELLER_PRIVATE_KEY_PATH"),
|
|
196
|
+
"cert_content": os.getenv("TELLER_CERTIFICATE"),
|
|
197
|
+
"key_content": os.getenv("TELLER_PRIVATE_KEY"),
|
|
194
198
|
"environment": os.getenv("TELLER_ENVIRONMENT", "sandbox"),
|
|
195
199
|
}
|
|
196
200
|
elif provider == "plaid":
|
|
@@ -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-
|
|
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)
|
fin_infra/insights/__init__.py
CHANGED
|
@@ -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",
|
fin_infra/insights/aggregator.py
CHANGED
|
@@ -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(
|
|
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=
|
|
141
|
-
description=
|
|
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=
|
|
154
|
-
description=
|
|
155
|
-
action=
|
|
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(
|
|
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=
|
|
195
|
-
description=
|
|
196
|
-
action=
|
|
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=
|
|
209
|
-
description=
|
|
210
|
-
action=
|
|
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(
|
|
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=
|
|
245
|
-
description=
|
|
246
|
-
action=
|
|
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(
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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(
|
|
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",
|
|
287
|
-
description=opp.get("description",
|
|
288
|
-
action=opp.get("action",
|
|
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
|
)
|
fin_infra/models/brokerage.py
CHANGED
|
@@ -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")
|
|
@@ -52,25 +52,42 @@ class TellerClient(BankingProvider):
|
|
|
52
52
|
self,
|
|
53
53
|
cert_path: str | None = None,
|
|
54
54
|
key_path: str | None = None,
|
|
55
|
+
cert_content: str | None = None,
|
|
56
|
+
key_content: str | None = None,
|
|
55
57
|
environment: str = "sandbox",
|
|
56
58
|
timeout: float = 30.0,
|
|
57
59
|
) -> None:
|
|
58
60
|
"""Initialize TellerClient banking provider.
|
|
59
61
|
|
|
60
62
|
Args:
|
|
61
|
-
cert_path: Path to certificate.pem file
|
|
62
|
-
key_path: Path to private_key.pem file
|
|
63
|
+
cert_path: Path to certificate.pem file
|
|
64
|
+
key_path: Path to private_key.pem file
|
|
65
|
+
cert_content: Inline certificate PEM content (alternative to cert_path)
|
|
66
|
+
key_content: Inline private key PEM content (alternative to key_path)
|
|
63
67
|
environment: "sandbox" or "production" (default: sandbox)
|
|
64
68
|
timeout: HTTP request timeout in seconds (default: 30.0)
|
|
65
69
|
|
|
70
|
+
Note:
|
|
71
|
+
Either (cert_path, key_path) or (cert_content, key_content) must be provided
|
|
72
|
+
for production. The inline content options are useful for Railway/Vercel
|
|
73
|
+
where env vars are preferred over mounted files.
|
|
74
|
+
|
|
66
75
|
Raises:
|
|
67
|
-
ValueError: If cert/key
|
|
76
|
+
ValueError: If cert/key are missing in production environment
|
|
68
77
|
"""
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
has_file_creds = cert_path and key_path
|
|
79
|
+
has_inline_creds = cert_content and key_content
|
|
80
|
+
|
|
81
|
+
if environment == "production" and not (has_file_creds or has_inline_creds):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Either (cert_path, key_path) or (cert_content, key_content) "
|
|
84
|
+
"are required for production environment"
|
|
85
|
+
)
|
|
71
86
|
|
|
72
87
|
self.cert_path = cert_path
|
|
73
88
|
self.key_path = key_path
|
|
89
|
+
self.cert_content = cert_content
|
|
90
|
+
self.key_content = key_content
|
|
74
91
|
self.environment = environment
|
|
75
92
|
self.timeout = timeout
|
|
76
93
|
|
|
@@ -81,18 +98,36 @@ class TellerClient(BankingProvider):
|
|
|
81
98
|
self.base_url = "https://api.teller.io"
|
|
82
99
|
|
|
83
100
|
# Create HTTP client with mTLS certificate authentication
|
|
84
|
-
client_kwargs = {
|
|
101
|
+
client_kwargs: dict = {
|
|
85
102
|
"base_url": self.base_url,
|
|
86
103
|
"timeout": timeout,
|
|
87
104
|
"headers": {"User-Agent": "fin-infra/1.0"},
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
# Add certificate using SSL context (recommended approach, not deprecated)
|
|
91
|
-
if
|
|
92
|
-
#
|
|
108
|
+
if has_file_creds:
|
|
109
|
+
# Use file paths directly (assertions for type narrowing)
|
|
110
|
+
assert cert_path is not None
|
|
111
|
+
assert key_path is not None
|
|
93
112
|
ssl_context = ssl.create_default_context()
|
|
94
113
|
ssl_context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
95
114
|
client_kwargs["verify"] = ssl_context
|
|
115
|
+
elif has_inline_creds:
|
|
116
|
+
# Write inline content to temp files for SSL context (assertions for type narrowing)
|
|
117
|
+
assert cert_content is not None
|
|
118
|
+
assert key_content is not None
|
|
119
|
+
import tempfile
|
|
120
|
+
|
|
121
|
+
self._cert_file = tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False)
|
|
122
|
+
self._key_file = tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False)
|
|
123
|
+
self._cert_file.write(cert_content)
|
|
124
|
+
self._key_file.write(key_content)
|
|
125
|
+
self._cert_file.close()
|
|
126
|
+
self._key_file.close()
|
|
127
|
+
|
|
128
|
+
ssl_context = ssl.create_default_context()
|
|
129
|
+
ssl_context.load_cert_chain(certfile=self._cert_file.name, keyfile=self._key_file.name)
|
|
130
|
+
client_kwargs["verify"] = ssl_context
|
|
96
131
|
|
|
97
132
|
# Create client with explicit parameters to satisfy type checker
|
|
98
133
|
self.client = httpx.Client(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fin-infra
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
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=
|
|
7
|
-
fin_infra/analytics/models.py,sha256=
|
|
8
|
-
fin_infra/analytics/portfolio.py,sha256=
|
|
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=
|
|
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=
|
|
16
|
+
fin_infra/banking/__init__.py,sha256=ZkWhm9QsKyYYKZ4VJ6sw0HjFdUkO9-mTYtPpUXsJjuQ,24828
|
|
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=
|
|
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=
|
|
80
|
-
fin_infra/insights/aggregator.py,sha256=
|
|
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=
|
|
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
|
|
@@ -127,7 +129,7 @@ fin_infra/obs/classifier.py,sha256=S7kSphgHN1O4GiMUdr3IjuXpoXU0XgGq132_U-njXX4,5
|
|
|
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
131
|
fin_infra/providers/banking/plaid_client.py,sha256=6yaXbPxg4QvebZAXyUFUthBIAN1EuXN59BnmTi69n0U,7374
|
|
130
|
-
fin_infra/providers/banking/teller_client.py,sha256=
|
|
132
|
+
fin_infra/providers/banking/teller_client.py,sha256=c8wT0M0jLHdgxCxeNnJ7X9S8-qITUHEdd3oTWe_6z1U,12371
|
|
131
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
|
|
@@ -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.
|
|
178
|
-
fin_infra-0.
|
|
179
|
-
fin_infra-0.
|
|
180
|
-
fin_infra-0.
|
|
181
|
-
fin_infra-0.
|
|
179
|
+
fin_infra-0.8.0.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
|
|
180
|
+
fin_infra-0.8.0.dist-info/METADATA,sha256=9jjpWcj8Jb0gD9sX9xs0P3uJvm2Y4y6M53H_sfxzy7A,11050
|
|
181
|
+
fin_infra-0.8.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
182
|
+
fin_infra-0.8.0.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
|
|
183
|
+
fin_infra-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|