fin-infra 0.1.58__py3-none-any.whl → 0.1.60__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/__init__.py +11 -0
- fin_infra/analytics/spending.py +8 -7
- fin_infra/categorization/llm_layer.py +1 -1
- fin_infra/credit/experian/client.py +14 -26
- fin_infra/exceptions.py +613 -0
- fin_infra/investments/add.py +46 -21
- fin_infra/normalization/currency_converter.py +8 -10
- fin_infra/normalization/providers/exchangerate.py +6 -5
- fin_infra/normalization/symbol_resolver.py +7 -6
- fin_infra/providers/market/coingecko.py +6 -2
- fin_infra/providers/registry.py +11 -6
- fin_infra/utils/__init__.py +6 -1
- fin_infra/utils/retry.py +4 -3
- {fin_infra-0.1.58.dist-info → fin_infra-0.1.60.dist-info}/METADATA +1 -1
- {fin_infra-0.1.58.dist-info → fin_infra-0.1.60.dist-info}/RECORD +18 -18
- fin_infra/utils.py +0 -36
- {fin_infra-0.1.58.dist-info → fin_infra-0.1.60.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.58.dist-info → fin_infra-0.1.60.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.58.dist-info → fin_infra-0.1.60.dist-info}/entry_points.txt +0 -0
fin_infra/__init__.py
CHANGED
|
@@ -4,8 +4,19 @@ Public surface is intentionally small at this stage. Import from submodules for
|
|
|
4
4
|
specific domains (clients, models, markets, credit).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
FinInfraError,
|
|
9
|
+
ProviderError,
|
|
10
|
+
ProviderNotFoundError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
7
13
|
from .version import __version__
|
|
8
14
|
|
|
9
15
|
__all__ = [
|
|
10
16
|
"__version__",
|
|
17
|
+
# Base errors
|
|
18
|
+
"FinInfraError",
|
|
19
|
+
"ProviderError",
|
|
20
|
+
"ProviderNotFoundError",
|
|
21
|
+
"ValidationError",
|
|
11
22
|
]
|
fin_infra/analytics/spending.py
CHANGED
|
@@ -44,6 +44,7 @@ Examples:
|
|
|
44
44
|
|
|
45
45
|
from collections import defaultdict
|
|
46
46
|
from datetime import timedelta
|
|
47
|
+
from decimal import Decimal
|
|
47
48
|
from typing import Optional
|
|
48
49
|
|
|
49
50
|
from fin_infra.analytics.models import (
|
|
@@ -132,7 +133,7 @@ async def analyze_spending(
|
|
|
132
133
|
]
|
|
133
134
|
|
|
134
135
|
# Calculate top merchants
|
|
135
|
-
merchant_totals: dict[str,
|
|
136
|
+
merchant_totals: dict[str, Decimal] = defaultdict(Decimal)
|
|
136
137
|
for t in expense_transactions:
|
|
137
138
|
merchant = _extract_merchant_name(t.description or "Unknown")
|
|
138
139
|
merchant_totals[merchant] += abs(t.amount)
|
|
@@ -142,7 +143,7 @@ async def analyze_spending(
|
|
|
142
143
|
] # Top 10 merchants
|
|
143
144
|
|
|
144
145
|
# Calculate category breakdown
|
|
145
|
-
category_totals: dict[str,
|
|
146
|
+
category_totals: dict[str, Decimal] = defaultdict(Decimal)
|
|
146
147
|
for t in expense_transactions:
|
|
147
148
|
category = _get_transaction_category(t)
|
|
148
149
|
category_totals[category] += abs(t.amount)
|
|
@@ -261,7 +262,7 @@ def _get_transaction_category(transaction: Transaction) -> str:
|
|
|
261
262
|
|
|
262
263
|
async def _calculate_spending_trends(
|
|
263
264
|
user_id: str,
|
|
264
|
-
current_category_totals: dict[str,
|
|
265
|
+
current_category_totals: dict[str, Decimal],
|
|
265
266
|
current_period_days: int,
|
|
266
267
|
banking_provider=None,
|
|
267
268
|
categorization_provider=None,
|
|
@@ -288,7 +289,7 @@ async def _calculate_spending_trends(
|
|
|
288
289
|
for category, current_amount in current_category_totals.items():
|
|
289
290
|
# Mock: assume previous period was 10% lower on average
|
|
290
291
|
# In reality, would fetch historical data
|
|
291
|
-
previous_amount = current_amount * 0.9
|
|
292
|
+
previous_amount = current_amount * Decimal("0.9")
|
|
292
293
|
|
|
293
294
|
change_percent = (
|
|
294
295
|
((current_amount - previous_amount) / previous_amount) * 100
|
|
@@ -311,7 +312,7 @@ async def _calculate_spending_trends(
|
|
|
311
312
|
|
|
312
313
|
async def _detect_spending_anomalies(
|
|
313
314
|
user_id: str,
|
|
314
|
-
current_category_totals: dict[str,
|
|
315
|
+
current_category_totals: dict[str, Decimal],
|
|
315
316
|
current_period_days: int,
|
|
316
317
|
banking_provider=None,
|
|
317
318
|
categorization_provider=None,
|
|
@@ -339,7 +340,7 @@ async def _detect_spending_anomalies(
|
|
|
339
340
|
for category, current_amount in current_category_totals.items():
|
|
340
341
|
# Mock: assume historical average is current amount * 0.8
|
|
341
342
|
# In reality, would calculate from historical data
|
|
342
|
-
average_amount = current_amount * 0.8
|
|
343
|
+
average_amount = current_amount * Decimal("0.8")
|
|
343
344
|
|
|
344
345
|
deviation_percent = (
|
|
345
346
|
((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0
|
|
@@ -409,7 +410,7 @@ def _generate_mock_transactions(days: int) -> list[Transaction]:
|
|
|
409
410
|
Transaction(
|
|
410
411
|
id=f"mock_{i}",
|
|
411
412
|
account_id="mock_account",
|
|
412
|
-
amount=
|
|
413
|
+
amount=Decimal(str(amount)),
|
|
413
414
|
date=base_date - timedelta(days=days_ago),
|
|
414
415
|
description=description,
|
|
415
416
|
)
|
|
@@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
|
|
|
20
20
|
|
|
21
21
|
# ai-infra imports
|
|
22
22
|
try:
|
|
23
|
-
from ai_infra.llm import LLM
|
|
23
|
+
from ai_infra.llm import CoreLLM as LLM
|
|
24
24
|
from ai_infra.llm.providers import Providers
|
|
25
25
|
except ImportError:
|
|
26
26
|
raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
|
|
@@ -25,33 +25,21 @@ from tenacity import (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
from fin_infra.credit.experian.auth import ExperianAuthManager
|
|
28
|
+
from fin_infra.exceptions import (
|
|
29
|
+
ExperianAPIError,
|
|
30
|
+
ExperianAuthError,
|
|
31
|
+
ExperianNotFoundError,
|
|
32
|
+
ExperianRateLimitError,
|
|
33
|
+
)
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class ExperianRateLimitError(ExperianAPIError):
|
|
40
|
-
"""Raised when rate limit is exceeded (429)."""
|
|
41
|
-
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class ExperianAuthError(ExperianAPIError):
|
|
46
|
-
"""Raised when authentication fails (401)."""
|
|
47
|
-
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class ExperianNotFoundError(ExperianAPIError):
|
|
52
|
-
"""Raised when user not found in bureau (404)."""
|
|
53
|
-
|
|
54
|
-
pass
|
|
35
|
+
# Re-export for backward compatibility
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ExperianAPIError",
|
|
38
|
+
"ExperianAuthError",
|
|
39
|
+
"ExperianNotFoundError",
|
|
40
|
+
"ExperianRateLimitError",
|
|
41
|
+
"ExperianClient",
|
|
42
|
+
]
|
|
55
43
|
|
|
56
44
|
|
|
57
45
|
class ExperianClient:
|
fin_infra/exceptions.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""Unified exception hierarchy for fin-infra.
|
|
2
|
+
|
|
3
|
+
This module provides a consistent exception hierarchy across all fin-infra components:
|
|
4
|
+
- Provider errors (API failures, authentication, rate limits)
|
|
5
|
+
- Normalization errors (currency, symbol resolution)
|
|
6
|
+
- Validation errors (data validation, compliance)
|
|
7
|
+
- Calculation errors (financial calculations)
|
|
8
|
+
|
|
9
|
+
All exceptions inherit from FinInfraError, allowing users to catch all library
|
|
10
|
+
errors with a single except clause.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
try:
|
|
14
|
+
accounts = await banking.get_accounts(token)
|
|
15
|
+
except FinInfraError as e:
|
|
16
|
+
print(f"Error: {e}")
|
|
17
|
+
if e.hint:
|
|
18
|
+
print(f"Hint: {e.hint}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Logging Helper
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def log_exception(
|
|
33
|
+
logger: logging.Logger,
|
|
34
|
+
msg: str,
|
|
35
|
+
exc: Exception,
|
|
36
|
+
*,
|
|
37
|
+
level: str = "warning",
|
|
38
|
+
include_traceback: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Log an exception with consistent formatting.
|
|
41
|
+
|
|
42
|
+
Use this helper instead of bare `except Exception:` blocks to ensure
|
|
43
|
+
all exceptions are properly logged with context.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
logger: The logger instance to use
|
|
47
|
+
msg: Context message describing what operation failed
|
|
48
|
+
exc: The exception that was caught
|
|
49
|
+
level: Log level - "debug", "info", "warning", "error", "critical"
|
|
50
|
+
include_traceback: Whether to include full traceback (exc_info=True)
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
try:
|
|
54
|
+
result = await provider.get_data()
|
|
55
|
+
except Exception as e:
|
|
56
|
+
log_exception(logger, "Failed to fetch data from provider", e)
|
|
57
|
+
# Handle gracefully or re-raise
|
|
58
|
+
"""
|
|
59
|
+
log_func = getattr(logger, level.lower(), logger.warning)
|
|
60
|
+
log_func(f"{msg}: {type(exc).__name__}: {exc}", exc_info=include_traceback)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Base Error
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FinInfraError(Exception):
|
|
69
|
+
"""Base exception for all fin-infra errors.
|
|
70
|
+
|
|
71
|
+
All fin-infra exceptions inherit from this, allowing users to catch
|
|
72
|
+
all library errors with a single except clause.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
message: Human-readable error description
|
|
76
|
+
details: Additional context as key-value pairs
|
|
77
|
+
hint: Suggested fix or action
|
|
78
|
+
docs_url: Link to relevant documentation
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
details: dict[str, Any] | None = None,
|
|
86
|
+
hint: str | None = None,
|
|
87
|
+
docs_url: str | None = None,
|
|
88
|
+
):
|
|
89
|
+
self.message = message
|
|
90
|
+
self.details = details or {}
|
|
91
|
+
self.hint = hint
|
|
92
|
+
self.docs_url = docs_url
|
|
93
|
+
|
|
94
|
+
# Build full message
|
|
95
|
+
full_msg = message
|
|
96
|
+
if hint:
|
|
97
|
+
full_msg += f"\n Hint: {hint}"
|
|
98
|
+
if docs_url:
|
|
99
|
+
full_msg += f"\n Docs: {docs_url}"
|
|
100
|
+
|
|
101
|
+
super().__init__(full_msg)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"{self.__class__.__name__}({self.message!r})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# =============================================================================
|
|
108
|
+
# Provider Errors
|
|
109
|
+
# =============================================================================
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ProviderError(FinInfraError):
|
|
113
|
+
"""Base error for provider operations (banking, brokerage, credit, etc.).
|
|
114
|
+
|
|
115
|
+
Raised when a financial data provider returns an error.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
message: str,
|
|
121
|
+
*,
|
|
122
|
+
provider: str | None = None,
|
|
123
|
+
status_code: int | None = None,
|
|
124
|
+
details: dict[str, Any] | None = None,
|
|
125
|
+
hint: str | None = None,
|
|
126
|
+
docs_url: str | None = None,
|
|
127
|
+
):
|
|
128
|
+
self.provider = provider
|
|
129
|
+
self.status_code = status_code
|
|
130
|
+
|
|
131
|
+
# Add provider info to details
|
|
132
|
+
full_details = details or {}
|
|
133
|
+
if provider:
|
|
134
|
+
full_details["provider"] = provider
|
|
135
|
+
if status_code:
|
|
136
|
+
full_details["status_code"] = status_code
|
|
137
|
+
|
|
138
|
+
super().__init__(message, details=full_details, hint=hint, docs_url=docs_url)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ProviderNotFoundError(ProviderError):
|
|
142
|
+
"""Provider not found in registry."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
provider_key: str,
|
|
147
|
+
*,
|
|
148
|
+
available_providers: list[str] | None = None,
|
|
149
|
+
details: dict[str, Any] | None = None,
|
|
150
|
+
):
|
|
151
|
+
hint = None
|
|
152
|
+
if available_providers:
|
|
153
|
+
hint = f"Available providers: {', '.join(available_providers)}"
|
|
154
|
+
|
|
155
|
+
super().__init__(
|
|
156
|
+
f"Provider '{provider_key}' not found",
|
|
157
|
+
details=details,
|
|
158
|
+
hint=hint,
|
|
159
|
+
)
|
|
160
|
+
self.provider_key = provider_key
|
|
161
|
+
self.available_providers = available_providers
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ProviderAPIError(ProviderError):
|
|
165
|
+
"""API error from a provider."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
message: str,
|
|
170
|
+
*,
|
|
171
|
+
provider: str | None = None,
|
|
172
|
+
status_code: int | None = None,
|
|
173
|
+
error_type: str | None = None,
|
|
174
|
+
response: dict[str, Any] | None = None,
|
|
175
|
+
details: dict[str, Any] | None = None,
|
|
176
|
+
hint: str | None = None,
|
|
177
|
+
):
|
|
178
|
+
self.error_type = error_type
|
|
179
|
+
self.response = response
|
|
180
|
+
|
|
181
|
+
full_details = details or {}
|
|
182
|
+
if error_type:
|
|
183
|
+
full_details["error_type"] = error_type
|
|
184
|
+
if response:
|
|
185
|
+
full_details["response"] = response
|
|
186
|
+
|
|
187
|
+
super().__init__(
|
|
188
|
+
message,
|
|
189
|
+
provider=provider,
|
|
190
|
+
status_code=status_code,
|
|
191
|
+
details=full_details,
|
|
192
|
+
hint=hint,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ProviderAuthError(ProviderAPIError):
|
|
197
|
+
"""Authentication failed with provider (401)."""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
message: str = "Authentication failed",
|
|
202
|
+
*,
|
|
203
|
+
provider: str | None = None,
|
|
204
|
+
details: dict[str, Any] | None = None,
|
|
205
|
+
):
|
|
206
|
+
hint = "Check your API credentials"
|
|
207
|
+
if provider:
|
|
208
|
+
hint = f"Check your {provider.upper()} API credentials"
|
|
209
|
+
|
|
210
|
+
super().__init__(
|
|
211
|
+
message,
|
|
212
|
+
provider=provider,
|
|
213
|
+
status_code=401,
|
|
214
|
+
error_type="Unauthorized",
|
|
215
|
+
details=details,
|
|
216
|
+
hint=hint,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ProviderRateLimitError(ProviderAPIError):
|
|
221
|
+
"""Rate limit exceeded from provider (429)."""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
message: str = "Rate limit exceeded",
|
|
226
|
+
*,
|
|
227
|
+
provider: str | None = None,
|
|
228
|
+
retry_after: float | None = None,
|
|
229
|
+
details: dict[str, Any] | None = None,
|
|
230
|
+
):
|
|
231
|
+
self.retry_after = retry_after
|
|
232
|
+
|
|
233
|
+
hint = "Wait and retry the request"
|
|
234
|
+
if retry_after:
|
|
235
|
+
hint = f"Retry after {retry_after} seconds"
|
|
236
|
+
|
|
237
|
+
full_details = details or {}
|
|
238
|
+
if retry_after:
|
|
239
|
+
full_details["retry_after"] = retry_after
|
|
240
|
+
|
|
241
|
+
super().__init__(
|
|
242
|
+
message,
|
|
243
|
+
provider=provider,
|
|
244
|
+
status_code=429,
|
|
245
|
+
error_type="Too Many Requests",
|
|
246
|
+
details=full_details,
|
|
247
|
+
hint=hint,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ProviderNotFoundResourceError(ProviderAPIError):
|
|
252
|
+
"""Resource not found at provider (404)."""
|
|
253
|
+
|
|
254
|
+
def __init__(
|
|
255
|
+
self,
|
|
256
|
+
message: str,
|
|
257
|
+
*,
|
|
258
|
+
provider: str | None = None,
|
|
259
|
+
resource_type: str | None = None,
|
|
260
|
+
resource_id: str | None = None,
|
|
261
|
+
details: dict[str, Any] | None = None,
|
|
262
|
+
):
|
|
263
|
+
self.resource_type = resource_type
|
|
264
|
+
self.resource_id = resource_id
|
|
265
|
+
|
|
266
|
+
full_details = details or {}
|
|
267
|
+
if resource_type:
|
|
268
|
+
full_details["resource_type"] = resource_type
|
|
269
|
+
if resource_id:
|
|
270
|
+
full_details["resource_id"] = resource_id
|
|
271
|
+
|
|
272
|
+
super().__init__(
|
|
273
|
+
message,
|
|
274
|
+
provider=provider,
|
|
275
|
+
status_code=404,
|
|
276
|
+
error_type="Not Found",
|
|
277
|
+
details=full_details,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# =============================================================================
|
|
282
|
+
# Credit Provider Errors (Experian, Equifax, TransUnion)
|
|
283
|
+
# =============================================================================
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class CreditError(ProviderError):
|
|
287
|
+
"""Base error for credit bureau operations."""
|
|
288
|
+
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ExperianAPIError(CreditError):
|
|
293
|
+
"""Error from Experian API."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
message: str,
|
|
298
|
+
*,
|
|
299
|
+
status_code: int | None = None,
|
|
300
|
+
response: dict[str, Any] | None = None,
|
|
301
|
+
details: dict[str, Any] | None = None,
|
|
302
|
+
hint: str | None = None,
|
|
303
|
+
):
|
|
304
|
+
self.response = response
|
|
305
|
+
|
|
306
|
+
full_details = details or {}
|
|
307
|
+
if response:
|
|
308
|
+
full_details["response"] = response
|
|
309
|
+
|
|
310
|
+
super().__init__(
|
|
311
|
+
message,
|
|
312
|
+
provider="experian",
|
|
313
|
+
status_code=status_code,
|
|
314
|
+
details=full_details,
|
|
315
|
+
hint=hint,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class ExperianRateLimitError(ExperianAPIError):
|
|
320
|
+
"""Experian rate limit exceeded (429)."""
|
|
321
|
+
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
message: str = "Experian rate limit exceeded",
|
|
325
|
+
*,
|
|
326
|
+
status_code: int | None = 429,
|
|
327
|
+
response: dict[str, Any] | None = None,
|
|
328
|
+
retry_after: float | None = None,
|
|
329
|
+
details: dict[str, Any] | None = None,
|
|
330
|
+
):
|
|
331
|
+
self.retry_after = retry_after
|
|
332
|
+
|
|
333
|
+
hint = "Wait and retry the request"
|
|
334
|
+
if retry_after:
|
|
335
|
+
hint = f"Retry after {retry_after} seconds"
|
|
336
|
+
|
|
337
|
+
super().__init__(
|
|
338
|
+
message,
|
|
339
|
+
status_code=status_code,
|
|
340
|
+
response=response,
|
|
341
|
+
details=details,
|
|
342
|
+
hint=hint,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ExperianAuthError(ExperianAPIError):
|
|
347
|
+
"""Experian authentication failed (401)."""
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
message: str = "Experian authentication failed",
|
|
352
|
+
*,
|
|
353
|
+
status_code: int | None = 401,
|
|
354
|
+
response: dict[str, Any] | None = None,
|
|
355
|
+
details: dict[str, Any] | None = None,
|
|
356
|
+
):
|
|
357
|
+
super().__init__(
|
|
358
|
+
message,
|
|
359
|
+
status_code=status_code,
|
|
360
|
+
response=response,
|
|
361
|
+
details=details,
|
|
362
|
+
hint="Check your EXPERIAN_CLIENT_ID and EXPERIAN_CLIENT_SECRET",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class ExperianNotFoundError(ExperianAPIError):
|
|
367
|
+
"""User not found in Experian bureau (404)."""
|
|
368
|
+
|
|
369
|
+
def __init__(
|
|
370
|
+
self,
|
|
371
|
+
message: str = "User not found in credit bureau",
|
|
372
|
+
*,
|
|
373
|
+
status_code: int | None = 404,
|
|
374
|
+
response: dict[str, Any] | None = None,
|
|
375
|
+
user_id: str | None = None,
|
|
376
|
+
details: dict[str, Any] | None = None,
|
|
377
|
+
):
|
|
378
|
+
self.user_id = user_id
|
|
379
|
+
|
|
380
|
+
full_details = details or {}
|
|
381
|
+
if user_id:
|
|
382
|
+
full_details["user_id"] = user_id
|
|
383
|
+
|
|
384
|
+
super().__init__(
|
|
385
|
+
message,
|
|
386
|
+
status_code=status_code,
|
|
387
|
+
response=response,
|
|
388
|
+
details=full_details,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# =============================================================================
|
|
393
|
+
# Normalization Errors
|
|
394
|
+
# =============================================================================
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class NormalizationError(FinInfraError):
|
|
398
|
+
"""Base error for normalization operations."""
|
|
399
|
+
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class CurrencyNotSupportedError(NormalizationError):
|
|
404
|
+
"""Currency code not supported."""
|
|
405
|
+
|
|
406
|
+
def __init__(
|
|
407
|
+
self,
|
|
408
|
+
currency: str,
|
|
409
|
+
*,
|
|
410
|
+
supported_currencies: list[str] | None = None,
|
|
411
|
+
details: dict[str, Any] | None = None,
|
|
412
|
+
):
|
|
413
|
+
hint = None
|
|
414
|
+
if supported_currencies:
|
|
415
|
+
# Show first 10 currencies as example
|
|
416
|
+
examples = supported_currencies[:10]
|
|
417
|
+
hint = f"Supported currencies include: {', '.join(examples)}..."
|
|
418
|
+
|
|
419
|
+
super().__init__(
|
|
420
|
+
f"Currency '{currency}' is not supported",
|
|
421
|
+
details=details,
|
|
422
|
+
hint=hint,
|
|
423
|
+
)
|
|
424
|
+
self.currency = currency
|
|
425
|
+
self.supported_currencies = supported_currencies
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class SymbolNotFoundError(NormalizationError):
|
|
429
|
+
"""Symbol could not be resolved."""
|
|
430
|
+
|
|
431
|
+
def __init__(
|
|
432
|
+
self,
|
|
433
|
+
identifier: str,
|
|
434
|
+
*,
|
|
435
|
+
source_format: str | None = None,
|
|
436
|
+
target_format: str | None = None,
|
|
437
|
+
details: dict[str, Any] | None = None,
|
|
438
|
+
):
|
|
439
|
+
msg = f"Symbol '{identifier}' could not be resolved"
|
|
440
|
+
if source_format and target_format:
|
|
441
|
+
msg = f"Cannot convert '{identifier}' from {source_format} to {target_format}"
|
|
442
|
+
|
|
443
|
+
full_details = details or {}
|
|
444
|
+
if source_format:
|
|
445
|
+
full_details["source_format"] = source_format
|
|
446
|
+
if target_format:
|
|
447
|
+
full_details["target_format"] = target_format
|
|
448
|
+
|
|
449
|
+
super().__init__(msg, details=full_details)
|
|
450
|
+
self.identifier = identifier
|
|
451
|
+
self.source_format = source_format
|
|
452
|
+
self.target_format = target_format
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ExchangeRateAPIError(NormalizationError):
|
|
456
|
+
"""Error from exchange rate API."""
|
|
457
|
+
|
|
458
|
+
def __init__(
|
|
459
|
+
self,
|
|
460
|
+
message: str,
|
|
461
|
+
*,
|
|
462
|
+
status_code: int | None = None,
|
|
463
|
+
details: dict[str, Any] | None = None,
|
|
464
|
+
):
|
|
465
|
+
self.status_code = status_code
|
|
466
|
+
|
|
467
|
+
full_details = details or {}
|
|
468
|
+
if status_code:
|
|
469
|
+
full_details["status_code"] = status_code
|
|
470
|
+
|
|
471
|
+
super().__init__(message, details=full_details)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# =============================================================================
|
|
475
|
+
# Validation Errors
|
|
476
|
+
# =============================================================================
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class ValidationError(FinInfraError):
|
|
480
|
+
"""Base error for validation failures."""
|
|
481
|
+
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class ComplianceError(ValidationError):
|
|
486
|
+
"""Compliance validation failed (FCRA, PCI-DSS, etc.)."""
|
|
487
|
+
|
|
488
|
+
def __init__(
|
|
489
|
+
self,
|
|
490
|
+
message: str,
|
|
491
|
+
*,
|
|
492
|
+
regulation: str | None = None,
|
|
493
|
+
details: dict[str, Any] | None = None,
|
|
494
|
+
):
|
|
495
|
+
self.regulation = regulation
|
|
496
|
+
|
|
497
|
+
full_details = details or {}
|
|
498
|
+
if regulation:
|
|
499
|
+
full_details["regulation"] = regulation
|
|
500
|
+
|
|
501
|
+
super().__init__(message, details=full_details)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# =============================================================================
|
|
505
|
+
# Calculation Errors
|
|
506
|
+
# =============================================================================
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class CalculationError(FinInfraError):
|
|
510
|
+
"""Base error for financial calculation failures."""
|
|
511
|
+
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class InsufficientDataError(CalculationError):
|
|
516
|
+
"""Not enough data to perform calculation."""
|
|
517
|
+
|
|
518
|
+
def __init__(
|
|
519
|
+
self,
|
|
520
|
+
message: str,
|
|
521
|
+
*,
|
|
522
|
+
required_fields: list[str] | None = None,
|
|
523
|
+
details: dict[str, Any] | None = None,
|
|
524
|
+
):
|
|
525
|
+
self.required_fields = required_fields
|
|
526
|
+
|
|
527
|
+
full_details = details or {}
|
|
528
|
+
if required_fields:
|
|
529
|
+
full_details["required_fields"] = required_fields
|
|
530
|
+
|
|
531
|
+
hint = None
|
|
532
|
+
if required_fields:
|
|
533
|
+
hint = f"Required fields: {', '.join(required_fields)}"
|
|
534
|
+
|
|
535
|
+
super().__init__(message, details=full_details, hint=hint)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# =============================================================================
|
|
539
|
+
# Retry/Network Errors
|
|
540
|
+
# =============================================================================
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class RetryError(FinInfraError):
|
|
544
|
+
"""Retry limit exceeded after multiple attempts."""
|
|
545
|
+
|
|
546
|
+
def __init__(
|
|
547
|
+
self,
|
|
548
|
+
message: str = "Operation failed after max retries",
|
|
549
|
+
*,
|
|
550
|
+
attempts: int | None = None,
|
|
551
|
+
last_exception: Exception | None = None,
|
|
552
|
+
details: dict[str, Any] | None = None,
|
|
553
|
+
):
|
|
554
|
+
self.attempts = attempts
|
|
555
|
+
self.last_exception = last_exception
|
|
556
|
+
|
|
557
|
+
full_details = details or {}
|
|
558
|
+
if attempts:
|
|
559
|
+
full_details["attempts"] = attempts
|
|
560
|
+
if last_exception:
|
|
561
|
+
full_details["last_exception"] = str(last_exception)
|
|
562
|
+
|
|
563
|
+
super().__init__(message, details=full_details)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# =============================================================================
|
|
567
|
+
# Convenience aliases for backward compatibility
|
|
568
|
+
# =============================================================================
|
|
569
|
+
|
|
570
|
+
# Keep short names for commonly used errors
|
|
571
|
+
APIError = ProviderAPIError
|
|
572
|
+
AuthError = ProviderAuthError
|
|
573
|
+
RateLimitError = ProviderRateLimitError
|
|
574
|
+
NotFoundError = ProviderNotFoundResourceError
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
__all__ = [
|
|
578
|
+
# Logging helper
|
|
579
|
+
"log_exception",
|
|
580
|
+
# Base
|
|
581
|
+
"FinInfraError",
|
|
582
|
+
# Provider errors
|
|
583
|
+
"ProviderError",
|
|
584
|
+
"ProviderNotFoundError",
|
|
585
|
+
"ProviderAPIError",
|
|
586
|
+
"ProviderAuthError",
|
|
587
|
+
"ProviderRateLimitError",
|
|
588
|
+
"ProviderNotFoundResourceError",
|
|
589
|
+
# Credit provider errors
|
|
590
|
+
"CreditError",
|
|
591
|
+
"ExperianAPIError",
|
|
592
|
+
"ExperianRateLimitError",
|
|
593
|
+
"ExperianAuthError",
|
|
594
|
+
"ExperianNotFoundError",
|
|
595
|
+
# Normalization errors
|
|
596
|
+
"NormalizationError",
|
|
597
|
+
"CurrencyNotSupportedError",
|
|
598
|
+
"SymbolNotFoundError",
|
|
599
|
+
"ExchangeRateAPIError",
|
|
600
|
+
# Validation errors
|
|
601
|
+
"ValidationError",
|
|
602
|
+
"ComplianceError",
|
|
603
|
+
# Calculation errors
|
|
604
|
+
"CalculationError",
|
|
605
|
+
"InsufficientDataError",
|
|
606
|
+
# Retry errors
|
|
607
|
+
"RetryError",
|
|
608
|
+
# Aliases
|
|
609
|
+
"APIError",
|
|
610
|
+
"AuthError",
|
|
611
|
+
"RateLimitError",
|
|
612
|
+
"NotFoundError",
|
|
613
|
+
]
|
fin_infra/investments/add.py
CHANGED
|
@@ -220,10 +220,15 @@ def add_investments(
|
|
|
220
220
|
)
|
|
221
221
|
|
|
222
222
|
# Call provider with resolved token
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
try:
|
|
224
|
+
holdings = await investment_provider.get_holdings(
|
|
225
|
+
access_token=access_token,
|
|
226
|
+
account_ids=request.account_ids,
|
|
227
|
+
)
|
|
228
|
+
except ValueError as e:
|
|
229
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch holdings: {e}")
|
|
227
232
|
return holdings
|
|
228
233
|
|
|
229
234
|
@router.post(
|
|
@@ -266,12 +271,17 @@ def add_investments(
|
|
|
266
271
|
detail="No access token found. Please reconnect your accounts."
|
|
267
272
|
)
|
|
268
273
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
274
|
+
try:
|
|
275
|
+
transactions = await investment_provider.get_transactions(
|
|
276
|
+
access_token=access_token,
|
|
277
|
+
start_date=request.start_date,
|
|
278
|
+
end_date=request.end_date,
|
|
279
|
+
account_ids=request.account_ids,
|
|
280
|
+
)
|
|
281
|
+
except ValueError as e:
|
|
282
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
283
|
+
except Exception as e:
|
|
284
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch transactions: {e}")
|
|
275
285
|
return transactions
|
|
276
286
|
|
|
277
287
|
@router.post(
|
|
@@ -306,9 +316,14 @@ def add_investments(
|
|
|
306
316
|
detail="No access token found. Please reconnect your accounts."
|
|
307
317
|
)
|
|
308
318
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
319
|
+
try:
|
|
320
|
+
accounts = await investment_provider.get_investment_accounts(
|
|
321
|
+
access_token=access_token,
|
|
322
|
+
)
|
|
323
|
+
except ValueError as e:
|
|
324
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
325
|
+
except Exception as e:
|
|
326
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch accounts: {e}")
|
|
312
327
|
return accounts
|
|
313
328
|
|
|
314
329
|
@router.post(
|
|
@@ -344,10 +359,15 @@ def add_investments(
|
|
|
344
359
|
)
|
|
345
360
|
|
|
346
361
|
# Fetch holdings
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
362
|
+
try:
|
|
363
|
+
holdings = await investment_provider.get_holdings(
|
|
364
|
+
access_token=access_token,
|
|
365
|
+
account_ids=request.account_ids,
|
|
366
|
+
)
|
|
367
|
+
except ValueError as e:
|
|
368
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
369
|
+
except Exception as e:
|
|
370
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch allocation: {e}")
|
|
351
371
|
|
|
352
372
|
# Calculate allocation using base provider helper
|
|
353
373
|
allocation = investment_provider.calculate_allocation(holdings)
|
|
@@ -385,10 +405,15 @@ def add_investments(
|
|
|
385
405
|
detail="No access token found. Please reconnect your accounts."
|
|
386
406
|
)
|
|
387
407
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
408
|
+
try:
|
|
409
|
+
securities = await investment_provider.get_securities(
|
|
410
|
+
access_token=access_token,
|
|
411
|
+
security_ids=request.security_ids,
|
|
412
|
+
)
|
|
413
|
+
except ValueError as e:
|
|
414
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
415
|
+
except Exception as e:
|
|
416
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch securities: {e}")
|
|
392
417
|
return securities
|
|
393
418
|
|
|
394
419
|
# 5. Mount router
|
|
@@ -4,19 +4,17 @@ import logging
|
|
|
4
4
|
from datetime import date as DateType
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
+
from fin_infra.exceptions import CurrencyNotSupportedError, ExchangeRateAPIError
|
|
7
8
|
from fin_infra.normalization.models import CurrencyConversionResult
|
|
8
|
-
from fin_infra.normalization.providers.exchangerate import
|
|
9
|
-
ExchangeRateAPIError,
|
|
10
|
-
ExchangeRateClient,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
9
|
+
from fin_infra.normalization.providers.exchangerate import ExchangeRateClient
|
|
14
10
|
|
|
11
|
+
# Re-export for backward compatibility
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CurrencyNotSupportedError",
|
|
14
|
+
"CurrencyConverter",
|
|
15
|
+
]
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
"""Currency code not supported."""
|
|
18
|
-
|
|
19
|
-
pass
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
20
18
|
|
|
21
19
|
|
|
22
20
|
class CurrencyConverter:
|
|
@@ -6,13 +6,14 @@ from typing import Optional
|
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
9
|
+
from fin_infra.exceptions import ExchangeRateAPIError
|
|
9
10
|
from fin_infra.normalization.models import ExchangeRate
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
""
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
# Re-export for backward compatibility
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ExchangeRateAPIError",
|
|
15
|
+
"ExchangeRateClient",
|
|
16
|
+
]
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class ExchangeRateClient:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
+
from fin_infra.exceptions import SymbolNotFoundError
|
|
6
7
|
from fin_infra.normalization.models import SymbolMetadata
|
|
7
8
|
from fin_infra.normalization.providers import (
|
|
8
9
|
CUSIP_TO_TICKER,
|
|
@@ -13,13 +14,13 @@ from fin_infra.normalization.providers import (
|
|
|
13
14
|
TICKER_TO_ISIN,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Re-export for backward compatibility
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SymbolNotFoundError",
|
|
20
|
+
"SymbolResolver",
|
|
21
|
+
]
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
"""Symbol could not be resolved."""
|
|
21
|
-
|
|
22
|
-
pass
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class SymbolResolver:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import httpx
|
|
4
5
|
from decimal import Decimal
|
|
5
6
|
from datetime import datetime, timezone
|
|
@@ -7,6 +8,7 @@ from datetime import datetime, timezone
|
|
|
7
8
|
from ..base import CryptoDataProvider
|
|
8
9
|
from ...models import Quote, Candle
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
_BASE = "https://api.coingecko.com/api/v3"
|
|
12
14
|
|
|
@@ -25,7 +27,8 @@ class CoinGeckoCryptoData(CryptoDataProvider):
|
|
|
25
27
|
r.raise_for_status()
|
|
26
28
|
data = r.json()
|
|
27
29
|
price = data.get(_to_cg_id(base), {}).get(quote.lower(), 0)
|
|
28
|
-
except Exception:
|
|
30
|
+
except Exception as e:
|
|
31
|
+
logger.warning("CoinGecko ticker fetch failed for %s: %s", symbol_pair, e)
|
|
29
32
|
price = 0
|
|
30
33
|
return Quote(
|
|
31
34
|
symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(timezone.utc)
|
|
@@ -42,7 +45,8 @@ class CoinGeckoCryptoData(CryptoDataProvider):
|
|
|
42
45
|
)
|
|
43
46
|
r.raise_for_status()
|
|
44
47
|
prices = r.json().get("prices", [])
|
|
45
|
-
except Exception:
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.warning("CoinGecko OHLCV fetch failed for %s: %s", symbol_pair, e)
|
|
46
50
|
prices = []
|
|
47
51
|
out: list[Candle] = []
|
|
48
52
|
for p in prices[:limit]:
|
fin_infra/providers/registry.py
CHANGED
|
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|
|
13
13
|
import importlib
|
|
14
14
|
from typing import Any, TypeVar
|
|
15
15
|
|
|
16
|
+
from fin_infra.exceptions import ProviderNotFoundError
|
|
17
|
+
|
|
16
18
|
from .base import (
|
|
17
19
|
BankingProvider,
|
|
18
20
|
BrokerageProvider,
|
|
@@ -23,6 +25,15 @@ from .base import (
|
|
|
23
25
|
TaxProvider,
|
|
24
26
|
)
|
|
25
27
|
|
|
28
|
+
# Re-export for backward compatibility
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ProviderNotFoundError",
|
|
31
|
+
"ProviderRegistry",
|
|
32
|
+
"PROVIDER_TYPES",
|
|
33
|
+
"PROVIDER_MODULES",
|
|
34
|
+
"DEFAULT_PROVIDERS",
|
|
35
|
+
]
|
|
36
|
+
|
|
26
37
|
T = TypeVar("T")
|
|
27
38
|
|
|
28
39
|
# Provider domain to ABC mapping
|
|
@@ -77,12 +88,6 @@ DEFAULT_PROVIDERS = {
|
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
|
|
80
|
-
class ProviderNotFoundError(Exception):
|
|
81
|
-
"""Raised when a provider cannot be found or loaded."""
|
|
82
|
-
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
|
|
86
91
|
class ProviderRegistry:
|
|
87
92
|
"""
|
|
88
93
|
Registry for financial data providers.
|
fin_infra/utils/__init__.py
CHANGED
|
@@ -7,6 +7,11 @@ no local HTTP/retry wrappers to avoid duplication.
|
|
|
7
7
|
Scaffold utilities for template-based code generation are provided by svc-infra
|
|
8
8
|
and should be imported from there:
|
|
9
9
|
from svc_infra.utils import render_template, write, ensure_init_py
|
|
10
|
+
|
|
11
|
+
For async retry with exponential backoff:
|
|
12
|
+
from fin_infra.utils.retry import retry_async, RetryError
|
|
10
13
|
"""
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
from fin_infra.utils.retry import RetryError, retry_async
|
|
16
|
+
|
|
17
|
+
__all__ = ["RetryError", "retry_async"]
|
fin_infra/utils/retry.py
CHANGED
|
@@ -4,11 +4,12 @@ import asyncio
|
|
|
4
4
|
import random
|
|
5
5
|
from typing import Awaitable, Callable, Iterable, TypeVar
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
from fin_infra.exceptions import RetryError
|
|
8
8
|
|
|
9
|
+
# Re-export for backward compatibility
|
|
10
|
+
__all__ = ["RetryError", "retry_async"]
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
pass
|
|
12
|
+
T = TypeVar("T")
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
async def retry_async(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fin-infra
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.60
|
|
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
fin_infra/__init__.py,sha256=
|
|
1
|
+
fin_infra/__init__.py,sha256=5vcHN0u9CE43mnK2VgiMyWTiyANupUISawvxx-2AfqE,480
|
|
2
2
|
fin_infra/__main__.py,sha256=1qNP7j0ffw0wFs1dBwDcJ9TNXlC6FcYuulzoV87pMi8,262
|
|
3
3
|
fin_infra/analytics/__init__.py,sha256=aiuNnii0vc34XlNzrSiMbz33lzgmR7W1nHkmAEXavCY,1870
|
|
4
4
|
fin_infra/analytics/add.py,sha256=a8ZcVc0-3gxq64IFs9fN7XVMayzE6RCCnBWV3zzc7GU,12832
|
|
@@ -10,7 +10,7 @@ fin_infra/analytics/projections.py,sha256=7cuG6w1KXq8sd3UNufu5aOcxG5n-foswrHqrgW
|
|
|
10
10
|
fin_infra/analytics/rebalancing.py,sha256=K3S7KQiIU2LwyAwWN9VrSly4AOl24vN9tz_JX7I9FJ8,14642
|
|
11
11
|
fin_infra/analytics/savings.py,sha256=tavIRZtu9FjCm-DeWg5f060GcsdgD-cl-vgKOnieOUw,7574
|
|
12
12
|
fin_infra/analytics/scenarios.py,sha256=LE_dZVkbxxAx5sxitGhiOhZfWTlYtVbIvS9pEXkijLc,12246
|
|
13
|
-
fin_infra/analytics/spending.py,sha256=
|
|
13
|
+
fin_infra/analytics/spending.py,sha256=Md8iOFKkvFbNmIGV56mVDTYJbZ7oNtK-rMAZiNX2j2E,25902
|
|
14
14
|
fin_infra/banking/__init__.py,sha256=wva1SEyrH2po79YycQ_00ZyC2tVeuO3uYcyvudOW484,22267
|
|
15
15
|
fin_infra/banking/history.py,sha256=1ufAwkTnXr-QJetFzJl4xA2e3dqd1-TkT8pf46MNfho,10630
|
|
16
16
|
fin_infra/banking/utils.py,sha256=B2ebnTeUz-56l8XMBWnf2txFOr0bXIo3cKPio7_bhc4,15711
|
|
@@ -33,7 +33,7 @@ fin_infra/categorization/__init__.py,sha256=7551OjE668A_Bhm07QSTBkm4PD3uCOEwdz05
|
|
|
33
33
|
fin_infra/categorization/add.py,sha256=jbxM51MyIFsAcleCMzP1I5jYV9EsKALzBCnuzKk76sc,6328
|
|
34
34
|
fin_infra/categorization/ease.py,sha256=NudJBqFByS0YONPn_4O_Q7QYIiVCCgNbAhn-ugJpa0Y,5826
|
|
35
35
|
fin_infra/categorization/engine.py,sha256=VxVuLym_RkKK0xpZrfLKuksFVoURmXICgdik7KpxXMs,12075
|
|
36
|
-
fin_infra/categorization/llm_layer.py,sha256=
|
|
36
|
+
fin_infra/categorization/llm_layer.py,sha256=kRPtxLyIpCsYjH1NtaIL_WvJX5Y9tOsdDkYbFCTggSU,12706
|
|
37
37
|
fin_infra/categorization/models.py,sha256=O8ceQOM0ljRh0jkmnjV7CK5Jyq1DI3lG07UTeeMheNg,5931
|
|
38
38
|
fin_infra/categorization/rules.py,sha256=m3OogJY0hJe5BrmZqOvOKS2-HRdW4Y5jvvtlPDn9Pn8,12884
|
|
39
39
|
fin_infra/categorization/taxonomy.py,sha256=qsgo7VJkM6GFBBOaTRHWP82vl5SinRKnMsj4ICarEyQ,13281
|
|
@@ -51,7 +51,7 @@ fin_infra/credit/__init__.py,sha256=cwCP_WlrG-0yb_L4zYsuzEsSalcfiCY9ItqXfD7Jx9E,
|
|
|
51
51
|
fin_infra/credit/add.py,sha256=etRbqw15vzUQfvnMTmznZlLiKy2GVEe8ok08Ea3pjdE,8490
|
|
52
52
|
fin_infra/credit/experian/__init__.py,sha256=g3IJGvDOMsnB0er0Uwdvl6hGKKTOazqJxSDnB2oIBm0,761
|
|
53
53
|
fin_infra/credit/experian/auth.py,sha256=SHi3YNPFwEAS_SraAiAK7V-DEokgaq-7-eqkkBrcgMo,5562
|
|
54
|
-
fin_infra/credit/experian/client.py,sha256=
|
|
54
|
+
fin_infra/credit/experian/client.py,sha256=crIO37qBoC4wGWH4X_-2cSosf7hX6kfVDQU1NTH58HE,8615
|
|
55
55
|
fin_infra/credit/experian/parser.py,sha256=7ptdLyTWWqHWqCo1CXn6L7XaIn9ZRRuOaATbFmMZZ64,7489
|
|
56
56
|
fin_infra/credit/experian/provider.py,sha256=iG2cyftdc7c2pvKWVfeNd3vF_ylNayyhgyUG7Jnl1VI,13766
|
|
57
57
|
fin_infra/credit/mock.py,sha256=xKWZk3fhuIYRfiZkNc9fbHUNViNKjmOLSj0MTI1f4ik,5356
|
|
@@ -64,6 +64,7 @@ fin_infra/documents/ease.py,sha256=rnxEIMjf6vLvD-h5WD4wM6PwmcB4iUtAtnvGxbFA5zA,9
|
|
|
64
64
|
fin_infra/documents/models.py,sha256=5MK5Mvs7s6HfNuNldT4xxwLGV4z1f7vNJLwDD-jalgw,6889
|
|
65
65
|
fin_infra/documents/ocr.py,sha256=cuXzrx6k3GIhiaB4-OMPyroB6GBdXuvXP7LAcs0ZV5o,9596
|
|
66
66
|
fin_infra/documents/storage.py,sha256=GS_GtUXLMIYqe2yHb9IaQFloRER3xeQ8fla_loozP68,10177
|
|
67
|
+
fin_infra/exceptions.py,sha256=woCazH0RxnGcrmsSA3NMZF4Ygr2dtI4tfzKNiFZ10AA,16953
|
|
67
68
|
fin_infra/goals/__init__.py,sha256=Vg8LKLlDoRiWHsJX7wu5Zcc-86NNLpHoLTjYVkGi2c4,2130
|
|
68
69
|
fin_infra/goals/add.py,sha256=cNf0H7EzssMeCYHBWQPW4lHoz1uUWhGMVUUqGMKhNtk,20566
|
|
69
70
|
fin_infra/goals/funding.py,sha256=6wn25N0VTYfKLzZWhEn0xdC0ft49qdElkQFc9IwmdPk,9334
|
|
@@ -79,7 +80,7 @@ fin_infra/insights/__init__.py,sha256=crIXNlztTCcYHNcEVMo8FwCTCUBwIK2wovb4HahzRY
|
|
|
79
80
|
fin_infra/insights/aggregator.py,sha256=XG32mN5w5Nc4AZllmfl1esL4q44mFAf0Fvj9mWev_zk,10249
|
|
80
81
|
fin_infra/insights/models.py,sha256=xov_YV8oBLJt3YdyVjbryRfcXqmGeGiPvZsZHSbvtl8,3202
|
|
81
82
|
fin_infra/investments/__init__.py,sha256=UiWvTdKH7V9aaqZLunPT1_QGfXBAZbPk_w4QmeLWLqo,6324
|
|
82
|
-
fin_infra/investments/add.py,sha256=
|
|
83
|
+
fin_infra/investments/add.py,sha256=3cbjXbWoTuDglwk9U48X6768Etv1XLTWysdDPgsn7Yg,17658
|
|
83
84
|
fin_infra/investments/ease.py,sha256=ocs7xvnZ1u8riFjH9KHi1yFEUF0lfuEcd-QMpsuiOu8,9229
|
|
84
85
|
fin_infra/investments/models.py,sha256=NHnkvtMa1QYp_TpuuqT4u3cWEJi3OhW-1e-orMuR47o,16107
|
|
85
86
|
fin_infra/investments/providers/__init__.py,sha256=V1eIzz6EnGJ-pq-9L3S2-evmcExF-YdZfd5P6JMyDtc,383
|
|
@@ -115,12 +116,12 @@ fin_infra/net_worth/scaffold_templates/models.py.tmpl,sha256=9BKsoD08RZbSdOm0wFT
|
|
|
115
116
|
fin_infra/net_worth/scaffold_templates/repository.py.tmpl,sha256=DSErnNxeAe4pWeefARRK3bU0hHltqdIFffENfVwdd7c,12798
|
|
116
117
|
fin_infra/net_worth/scaffold_templates/schemas.py.tmpl,sha256=VkFsxyZx4DFDhXDhn-7KT0IgrXCvgaS5ZdWbjyezWj0,4709
|
|
117
118
|
fin_infra/normalization/__init__.py,sha256=-7EP_lTExQpoCtgsx1wD3j8aMH9y3SlFgHke3mWCQI8,6195
|
|
118
|
-
fin_infra/normalization/currency_converter.py,sha256=
|
|
119
|
+
fin_infra/normalization/currency_converter.py,sha256=uuu8ASa5ppEniWLEVEpiDxXjZzln9nopWrhrATcD6Z4,7058
|
|
119
120
|
fin_infra/normalization/models.py,sha256=gNC9chpbQPRN58V2j__VEPVNReO1N8jH_AHObwGPWu0,1928
|
|
120
121
|
fin_infra/normalization/providers/__init__.py,sha256=LFU1tB2hVO42Yrkw-IDpPexD4mIlxob9lRrJEeGYqpE,559
|
|
121
|
-
fin_infra/normalization/providers/exchangerate.py,sha256=
|
|
122
|
+
fin_infra/normalization/providers/exchangerate.py,sha256=zOTcDYjKDeGpBjplnSB7XVQo_Zt6y0EdSIbzdziLkUs,6298
|
|
122
123
|
fin_infra/normalization/providers/static_mappings.py,sha256=m14VHmTZipbqrgyE0ABToabVx-pDcyB577LNWrACEUM,6809
|
|
123
|
-
fin_infra/normalization/symbol_resolver.py,sha256=
|
|
124
|
+
fin_infra/normalization/symbol_resolver.py,sha256=M7Li7LFiH4xpvxXcYQlJyk0iqgqpwaj6zQKsTzWZzas,8130
|
|
124
125
|
fin_infra/obs/__init__.py,sha256=kMMVl0fdwtJtZeKiusTuw0iO61Jo9-HNXsLmn3ffLRE,631
|
|
125
126
|
fin_infra/obs/classifier.py,sha256=6R2q-w71tk7WfXF5MBPqawxogcj6tILKZPlkpRZNDfg,5083
|
|
126
127
|
fin_infra/providers/__init__.py,sha256=jxhQm79T6DVXf7Wpy7luL-p50cE_IMUbjt4o3apzJQU,768
|
|
@@ -135,9 +136,9 @@ fin_infra/providers/identity/stripe_identity.py,sha256=JQGJRuQdWP5dWDcROgtz1Rrmp
|
|
|
135
136
|
fin_infra/providers/market/alphavantage.py,sha256=srZdkf-frBuKyPTdWasMmVrpnh76BEBDXa-nsYtLzNc,8963
|
|
136
137
|
fin_infra/providers/market/base.py,sha256=ljBzZTfjYQS9tXahmxFic7JQSZeyoiDMUZ1NY0R7yto,108
|
|
137
138
|
fin_infra/providers/market/ccxt_crypto.py,sha256=sqWu-718mGi7gUTIZKX4huJlMNLEIhpApIRFTBP915g,1054
|
|
138
|
-
fin_infra/providers/market/coingecko.py,sha256=
|
|
139
|
+
fin_infra/providers/market/coingecko.py,sha256=F1Bwdk28xSsIaFEuT7lhT3F6Vkd0Lp-CMp1rnYiLfaE,2702
|
|
139
140
|
fin_infra/providers/market/yahoo.py,sha256=FNhqkCFC0In-Z3zpzmuknEORHLRK5Evk2KSk0yysKjg,4954
|
|
140
|
-
fin_infra/providers/registry.py,sha256=
|
|
141
|
+
fin_infra/providers/registry.py,sha256=yPFmHHaSQERXZTcGkdXAtMU7rL7VwAzW4FOr14o6KS8,8409
|
|
141
142
|
fin_infra/providers/tax/__init__.py,sha256=Tq2gLyTXL_U_ht6r7HXgaDMCAPylgcRD2ZN-COjSSQU,207
|
|
142
143
|
fin_infra/providers/tax/irs.py,sha256=f7l6w0byprBszTlCB4ef60K8GrYV-03Dicl1a1Q2oVk,4701
|
|
143
144
|
fin_infra/providers/tax/mock.py,sha256=35QulDz-fmgXyibPt1cpMhL0WgGWeziOwHnlEd1QRd0,14415
|
|
@@ -168,13 +169,12 @@ fin_infra/settings.py,sha256=xitpBQJmuvSy9prQhvXOW1scbwB1KAyGD8XqYgU_hQU,1388
|
|
|
168
169
|
fin_infra/tax/__init__.py,sha256=NXUjV-k-rw4774pookY3UOwEXYRQauJze6Yift5RjW0,6107
|
|
169
170
|
fin_infra/tax/add.py,sha256=xmy0hXsWzEj5p-_9A5hkljFjF_FpnbCQQZ5e8FPChBI,14568
|
|
170
171
|
fin_infra/tax/tlh.py,sha256=QFnepLuJW8L71SccRTE54eN5ILyDGs1XNG5p-aVM_b8,21543
|
|
171
|
-
fin_infra/utils/__init__.py,sha256=
|
|
172
|
+
fin_infra/utils/__init__.py,sha256=gKacLSWMAis--pasd8AuVN7ap0e9Z1TjRGur0J23EDo,648
|
|
172
173
|
fin_infra/utils/http.py,sha256=wgXo5amXyzAX49v_lRUvp4Xxq8nodX32CMJyWl6u89I,568
|
|
173
|
-
fin_infra/utils/retry.py,sha256=
|
|
174
|
-
fin_infra/utils.py,sha256=VxT4ssP4r8Krl3KThvI-opPMhGCpZUCH4rUyit1LEUk,967
|
|
174
|
+
fin_infra/utils/retry.py,sha256=p4i4heGdHkLsqLHuHY4riwOkuLjbbfbUE8cA4t3UAgQ,1052
|
|
175
175
|
fin_infra/version.py,sha256=4t_crzhrLum--oyowUMxtjBTzUtWp7oRTF22ewEvJG4,49
|
|
176
|
-
fin_infra-0.1.
|
|
177
|
-
fin_infra-0.1.
|
|
178
|
-
fin_infra-0.1.
|
|
179
|
-
fin_infra-0.1.
|
|
180
|
-
fin_infra-0.1.
|
|
176
|
+
fin_infra-0.1.60.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
|
|
177
|
+
fin_infra-0.1.60.dist-info/METADATA,sha256=EmECBGkQ-fNJIJrOuC52HMfQm4HBUXFoJ16CvQOXYNM,10182
|
|
178
|
+
fin_infra-0.1.60.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
179
|
+
fin_infra-0.1.60.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
|
|
180
|
+
fin_infra-0.1.60.dist-info/RECORD,,
|
fin_infra/utils.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import random
|
|
5
|
-
from typing import Awaitable, Callable, Iterable, TypeVar
|
|
6
|
-
|
|
7
|
-
T = TypeVar("T")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class RetryError(Exception):
|
|
11
|
-
pass
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
async def retry_async(
|
|
15
|
-
func: Callable[[], Awaitable[T]],
|
|
16
|
-
*,
|
|
17
|
-
attempts: int = 3,
|
|
18
|
-
base_delay: float = 0.2,
|
|
19
|
-
jitter: float = 0.1,
|
|
20
|
-
retry_on: Iterable[type[BaseException]] = (Exception,),
|
|
21
|
-
) -> T:
|
|
22
|
-
"""Simple async retry with exponential backoff and jitter.
|
|
23
|
-
|
|
24
|
-
Not provider-specific; callers should keep idempotency in mind.
|
|
25
|
-
"""
|
|
26
|
-
last_exc: BaseException | None = None
|
|
27
|
-
for i in range(attempts):
|
|
28
|
-
try:
|
|
29
|
-
return await func()
|
|
30
|
-
except tuple(retry_on) as exc: # type: ignore[misc]
|
|
31
|
-
last_exc = exc
|
|
32
|
-
if i == attempts - 1:
|
|
33
|
-
break
|
|
34
|
-
delay = (2**i) * base_delay + random.uniform(0, jitter)
|
|
35
|
-
await asyncio.sleep(delay)
|
|
36
|
-
raise RetryError("Retry attempts exhausted") from last_exc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|