fin-infra 0.1.56__py3-none-any.whl → 0.1.58__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/banking/history.py +33 -0
- fin_infra/credit/experian/auth.py +9 -6
- fin_infra/credit/experian/provider.py +132 -14
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/providers/base.py +2 -1
- fin_infra/investments/providers/snaptrade.py +32 -18
- fin_infra/models/accounts.py +19 -3
- fin_infra/models/transactions.py +16 -2
- fin_infra/net_worth/calculator.py +32 -6
- fin_infra/providers/banking/plaid_client.py +10 -2
- fin_infra/providers/base.py +23 -3
- fin_infra/providers/brokerage/alpaca.py +11 -2
- fin_infra/settings.py +1 -1
- {fin_infra-0.1.56.dist-info → fin_infra-0.1.58.dist-info}/METADATA +1 -1
- {fin_infra-0.1.56.dist-info → fin_infra-0.1.58.dist-info}/RECORD +18 -18
- {fin_infra-0.1.56.dist-info → fin_infra-0.1.58.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.56.dist-info → fin_infra-0.1.58.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.56.dist-info → fin_infra-0.1.58.dist-info}/entry_points.txt +0 -0
fin_infra/banking/history.py
CHANGED
|
@@ -4,6 +4,9 @@ This module provides functionality to record and retrieve historical account bal
|
|
|
4
4
|
snapshots over time. This enables balance trend analysis, sparklines, and time-series
|
|
5
5
|
visualizations in fintech dashboards.
|
|
6
6
|
|
|
7
|
+
⚠️ WARNING: This module uses IN-MEMORY storage by default. All data is LOST on restart.
|
|
8
|
+
For production use, integrate with svc-infra SQL database or set FIN_INFRA_STORAGE_BACKEND.
|
|
9
|
+
|
|
7
10
|
Features:
|
|
8
11
|
- Record daily balance snapshots for accounts
|
|
9
12
|
- Store snapshots in time-series optimized format
|
|
@@ -36,6 +39,8 @@ Integration with svc-infra:
|
|
|
36
39
|
|
|
37
40
|
from __future__ import annotations
|
|
38
41
|
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
39
44
|
from datetime import date, datetime, timedelta
|
|
40
45
|
from typing import List, Optional
|
|
41
46
|
from pydantic import BaseModel, Field, ConfigDict
|
|
@@ -50,8 +55,31 @@ __all__ = [
|
|
|
50
55
|
]
|
|
51
56
|
|
|
52
57
|
|
|
58
|
+
_logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
53
60
|
# In-memory storage for testing (will be replaced with SQL database in production)
|
|
61
|
+
# ⚠️ WARNING: All data is LOST on restart when using in-memory storage!
|
|
54
62
|
_balance_snapshots: List[BalanceSnapshot] = []
|
|
63
|
+
_production_warning_logged = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_in_memory_warning() -> None:
|
|
67
|
+
"""Log a warning if using in-memory storage in production."""
|
|
68
|
+
global _production_warning_logged
|
|
69
|
+
if _production_warning_logged:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
env = os.getenv("ENV", "development").lower()
|
|
73
|
+
storage_backend = os.getenv("FIN_INFRA_STORAGE_BACKEND", "memory").lower()
|
|
74
|
+
|
|
75
|
+
if env in ("production", "staging") and storage_backend == "memory":
|
|
76
|
+
_logger.warning(
|
|
77
|
+
"⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
|
|
78
|
+
"All balance snapshots will be LOST on restart. "
|
|
79
|
+
"Set FIN_INFRA_STORAGE_BACKEND=sql for production persistence.",
|
|
80
|
+
env,
|
|
81
|
+
)
|
|
82
|
+
_production_warning_logged = True
|
|
55
83
|
|
|
56
84
|
|
|
57
85
|
class BalanceSnapshot(BaseModel):
|
|
@@ -87,6 +115,8 @@ def record_balance_snapshot(
|
|
|
87
115
|
This function stores a point-in-time balance record for trend analysis.
|
|
88
116
|
In production, this would write to a SQL database via svc-infra.
|
|
89
117
|
|
|
118
|
+
⚠️ WARNING: Uses in-memory storage by default. Data is LOST on restart!
|
|
119
|
+
|
|
90
120
|
Args:
|
|
91
121
|
account_id: Account identifier
|
|
92
122
|
balance: Account balance at the snapshot time
|
|
@@ -103,6 +133,9 @@ def record_balance_snapshot(
|
|
|
103
133
|
- In production, use unique constraint on (account_id, date) in SQL
|
|
104
134
|
- Consider using svc-infra jobs for automatic daily snapshots
|
|
105
135
|
"""
|
|
136
|
+
# Check if in-memory storage is being used in production
|
|
137
|
+
_check_in_memory_warning()
|
|
138
|
+
|
|
106
139
|
snapshot = BalanceSnapshot(
|
|
107
140
|
account_id=account_id,
|
|
108
141
|
balance=balance,
|
|
@@ -27,7 +27,6 @@ import base64
|
|
|
27
27
|
|
|
28
28
|
import httpx
|
|
29
29
|
from svc_infra.cache import cache_read
|
|
30
|
-
from svc_infra.cache.tags import invalidate_tags
|
|
31
30
|
|
|
32
31
|
# Cache key for OAuth tokens: "oauth_token:experian:{base_url_hash}"
|
|
33
32
|
# TTL: 3600 seconds (1 hour) - matches typical OAuth token expiry
|
|
@@ -91,7 +90,7 @@ class ExperianAuthManager:
|
|
|
91
90
|
@cache_read(
|
|
92
91
|
key="oauth_token:experian:{client_id}", # Use client_id for uniqueness
|
|
93
92
|
ttl=3600, # 1 hour - matches OAuth token expiry
|
|
94
|
-
tags=lambda **kw: ["oauth:experian"],
|
|
93
|
+
tags=lambda **kw: [f"oauth:experian:{kw['client_id']}"], # Client-specific tag
|
|
95
94
|
)
|
|
96
95
|
async def _get_token_cached(self, *, client_id: str) -> str:
|
|
97
96
|
"""Cached token getter (internal method).
|
|
@@ -144,10 +143,10 @@ class ExperianAuthManager:
|
|
|
144
143
|
return data["access_token"]
|
|
145
144
|
|
|
146
145
|
async def invalidate(self) -> None:
|
|
147
|
-
"""Invalidate cached token (force refresh on next get_token call).
|
|
146
|
+
"""Invalidate cached token for THIS client (force refresh on next get_token call).
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
Invalidates only the token for this specific client_id, not all Experian tokens.
|
|
149
|
+
Useful when token is rejected by API.
|
|
151
150
|
|
|
152
151
|
Example:
|
|
153
152
|
>>> try:
|
|
@@ -157,4 +156,8 @@ class ExperianAuthManager:
|
|
|
157
156
|
... await auth.invalidate()
|
|
158
157
|
... # Next get_token() will fetch new token
|
|
159
158
|
"""
|
|
160
|
-
|
|
159
|
+
# Import here to avoid circular import
|
|
160
|
+
from svc_infra.cache.tags import invalidate_tags
|
|
161
|
+
|
|
162
|
+
# Invalidate using client-specific tag, not all Experian tokens
|
|
163
|
+
await invalidate_tags(f"oauth:experian:{self.client_id}")
|
|
@@ -7,6 +7,7 @@ Features:
|
|
|
7
7
|
- HTTP client with retry logic
|
|
8
8
|
- Response parsing to Pydantic models
|
|
9
9
|
- FCRA compliance headers
|
|
10
|
+
- FCRA audit logging (required for regulatory compliance)
|
|
10
11
|
- Error handling
|
|
11
12
|
|
|
12
13
|
Example:
|
|
@@ -28,6 +29,8 @@ Example:
|
|
|
28
29
|
>>> print(len(report.accounts)) # Real credit accounts
|
|
29
30
|
"""
|
|
30
31
|
|
|
32
|
+
import logging
|
|
33
|
+
from datetime import datetime, timezone
|
|
31
34
|
from typing import Literal
|
|
32
35
|
|
|
33
36
|
from fin_infra.credit.experian.auth import ExperianAuthManager
|
|
@@ -37,6 +40,10 @@ from fin_infra.models.credit import CreditReport, CreditScore
|
|
|
37
40
|
from fin_infra.providers.base import CreditProvider
|
|
38
41
|
from fin_infra.settings import Settings
|
|
39
42
|
|
|
43
|
+
# FCRA audit logger - use dedicated logger for compliance auditing
|
|
44
|
+
# This should be configured to write to a tamper-evident, append-only log
|
|
45
|
+
fcra_audit_logger = logging.getLogger("fin_infra.fcra_audit")
|
|
46
|
+
|
|
40
47
|
|
|
41
48
|
class ExperianProvider(CreditProvider):
|
|
42
49
|
"""Experian credit bureau provider with real API integration.
|
|
@@ -143,11 +150,14 @@ class ExperianProvider(CreditProvider):
|
|
|
143
150
|
"""Retrieve current credit score for a user from Experian API.
|
|
144
151
|
|
|
145
152
|
Makes real API call to Experian. Uses FCRA-compliant permissible purpose.
|
|
153
|
+
All credit pulls are logged for FCRA compliance (15 USC § 1681b).
|
|
146
154
|
|
|
147
155
|
Args:
|
|
148
156
|
user_id: User identifier (SSN hash or internal ID)
|
|
149
157
|
**kwargs: Additional parameters
|
|
150
158
|
- permissible_purpose: FCRA purpose (default: "account_review")
|
|
159
|
+
- requester_ip: IP address of requester (for audit log)
|
|
160
|
+
- requester_user_id: ID of user/service making the request
|
|
151
161
|
|
|
152
162
|
Returns:
|
|
153
163
|
CreditScore with real FICO score from Experian
|
|
@@ -162,25 +172,79 @@ class ExperianProvider(CreditProvider):
|
|
|
162
172
|
>>> print(score.score) # Real FICO score (300-850)
|
|
163
173
|
"""
|
|
164
174
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
175
|
+
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
176
|
+
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
177
|
+
|
|
178
|
+
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
179
|
+
# This log must be retained for at least 2 years per FCRA requirements
|
|
180
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
181
|
+
fcra_audit_logger.info(
|
|
182
|
+
"FCRA_CREDIT_PULL",
|
|
183
|
+
extra={
|
|
184
|
+
"action": "credit_score_pull",
|
|
185
|
+
"subject_user_id": user_id,
|
|
186
|
+
"requester_user_id": requester_user_id,
|
|
187
|
+
"requester_ip": requester_ip,
|
|
188
|
+
"permissible_purpose": permissible_purpose,
|
|
189
|
+
"provider": "experian",
|
|
190
|
+
"environment": self.environment,
|
|
191
|
+
"timestamp": timestamp,
|
|
192
|
+
"result": "pending",
|
|
193
|
+
}
|
|
170
194
|
)
|
|
171
195
|
|
|
172
|
-
|
|
173
|
-
|
|
196
|
+
try:
|
|
197
|
+
# Fetch from Experian API
|
|
198
|
+
data = await self._client.get_credit_score(
|
|
199
|
+
user_id,
|
|
200
|
+
permissible_purpose=permissible_purpose,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Parse response to CreditScore model
|
|
204
|
+
result = parse_credit_score(data, user_id=user_id)
|
|
205
|
+
|
|
206
|
+
# Log successful pull
|
|
207
|
+
fcra_audit_logger.info(
|
|
208
|
+
"FCRA_CREDIT_PULL_SUCCESS",
|
|
209
|
+
extra={
|
|
210
|
+
"action": "credit_score_pull",
|
|
211
|
+
"subject_user_id": user_id,
|
|
212
|
+
"requester_user_id": requester_user_id,
|
|
213
|
+
"timestamp": timestamp,
|
|
214
|
+
"result": "success",
|
|
215
|
+
"score_returned": result.score is not None,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# Log failed pull - still required for FCRA audit trail
|
|
223
|
+
fcra_audit_logger.warning(
|
|
224
|
+
"FCRA_CREDIT_PULL_FAILED",
|
|
225
|
+
extra={
|
|
226
|
+
"action": "credit_score_pull",
|
|
227
|
+
"subject_user_id": user_id,
|
|
228
|
+
"requester_user_id": requester_user_id,
|
|
229
|
+
"timestamp": timestamp,
|
|
230
|
+
"result": "error",
|
|
231
|
+
"error_type": type(e).__name__,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
raise
|
|
174
235
|
|
|
175
236
|
async def get_credit_report(self, user_id: str, **kwargs) -> CreditReport:
|
|
176
237
|
"""Retrieve full credit report for a user from Experian API.
|
|
177
238
|
|
|
178
239
|
Makes real API call to Experian. Includes FCRA-required permissible purpose header.
|
|
240
|
+
All credit pulls are logged for FCRA compliance (15 USC § 1681b).
|
|
179
241
|
|
|
180
242
|
Args:
|
|
181
243
|
user_id: User identifier (SSN hash or internal ID)
|
|
182
244
|
**kwargs: Additional parameters
|
|
183
245
|
- permissible_purpose: FCRA purpose (default: "account_review")
|
|
246
|
+
- requester_ip: IP address of requester (for audit log)
|
|
247
|
+
- requester_user_id: ID of user/service making the request
|
|
184
248
|
|
|
185
249
|
Returns:
|
|
186
250
|
CreditReport with real credit data from Experian
|
|
@@ -196,15 +260,69 @@ class ExperianProvider(CreditProvider):
|
|
|
196
260
|
>>> print(report.score.score) # Real FICO score
|
|
197
261
|
"""
|
|
198
262
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
263
|
+
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
264
|
+
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
265
|
+
|
|
266
|
+
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
267
|
+
# Full credit report pulls have stricter requirements than score-only pulls
|
|
268
|
+
# This log must be retained for at least 2 years per FCRA requirements
|
|
269
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
270
|
+
fcra_audit_logger.info(
|
|
271
|
+
"FCRA_CREDIT_PULL",
|
|
272
|
+
extra={
|
|
273
|
+
"action": "credit_report_pull",
|
|
274
|
+
"subject_user_id": user_id,
|
|
275
|
+
"requester_user_id": requester_user_id,
|
|
276
|
+
"requester_ip": requester_ip,
|
|
277
|
+
"permissible_purpose": permissible_purpose,
|
|
278
|
+
"provider": "experian",
|
|
279
|
+
"environment": self.environment,
|
|
280
|
+
"timestamp": timestamp,
|
|
281
|
+
"result": "pending",
|
|
282
|
+
"report_type": "full",
|
|
283
|
+
}
|
|
204
284
|
)
|
|
205
285
|
|
|
206
|
-
|
|
207
|
-
|
|
286
|
+
try:
|
|
287
|
+
# Fetch from Experian API
|
|
288
|
+
data = await self._client.get_credit_report(
|
|
289
|
+
user_id,
|
|
290
|
+
permissible_purpose=permissible_purpose,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Parse response to CreditReport model
|
|
294
|
+
result = parse_credit_report(data, user_id=user_id)
|
|
295
|
+
|
|
296
|
+
# Log successful pull
|
|
297
|
+
fcra_audit_logger.info(
|
|
298
|
+
"FCRA_CREDIT_PULL_SUCCESS",
|
|
299
|
+
extra={
|
|
300
|
+
"action": "credit_report_pull",
|
|
301
|
+
"subject_user_id": user_id,
|
|
302
|
+
"requester_user_id": requester_user_id,
|
|
303
|
+
"timestamp": timestamp,
|
|
304
|
+
"result": "success",
|
|
305
|
+
"accounts_returned": len(result.accounts) if result.accounts else 0,
|
|
306
|
+
"inquiries_returned": len(result.inquiries) if result.inquiries else 0,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
# Log failed pull - still required for FCRA audit trail
|
|
314
|
+
fcra_audit_logger.warning(
|
|
315
|
+
"FCRA_CREDIT_PULL_FAILED",
|
|
316
|
+
extra={
|
|
317
|
+
"action": "credit_report_pull",
|
|
318
|
+
"subject_user_id": user_id,
|
|
319
|
+
"requester_user_id": requester_user_id,
|
|
320
|
+
"timestamp": timestamp,
|
|
321
|
+
"result": "error",
|
|
322
|
+
"error_type": type(e).__name__,
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
raise
|
|
208
326
|
|
|
209
327
|
async def subscribe_to_changes(self, user_id: str, webhook_url: str, **kwargs) -> str:
|
|
210
328
|
"""Subscribe to credit score change notifications from Experian.
|
fin_infra/investments/ease.py
CHANGED
|
@@ -46,7 +46,7 @@ def easy_investments(
|
|
|
46
46
|
Plaid:
|
|
47
47
|
- PLAID_CLIENT_ID: Plaid client ID
|
|
48
48
|
- PLAID_SECRET: Plaid secret key
|
|
49
|
-
-
|
|
49
|
+
- PLAID_ENVIRONMENT: Environment (sandbox/development/production), default: sandbox
|
|
50
50
|
|
|
51
51
|
SnapTrade:
|
|
52
52
|
- SNAPTRADE_CLIENT_ID: SnapTrade client ID
|
|
@@ -177,7 +177,7 @@ def _create_plaid_provider(**config: Any) -> InvestmentProvider:
|
|
|
177
177
|
# Get credentials from config or environment
|
|
178
178
|
client_id = config.get("client_id") or os.getenv("PLAID_CLIENT_ID")
|
|
179
179
|
secret = config.get("secret") or os.getenv("PLAID_SECRET")
|
|
180
|
-
environment = config.get("environment") or os.getenv("
|
|
180
|
+
environment = config.get("environment") or os.getenv("PLAID_ENVIRONMENT", "sandbox")
|
|
181
181
|
|
|
182
182
|
# Validate required credentials
|
|
183
183
|
if not client_id or not secret:
|
|
@@ -217,8 +217,9 @@ class InvestmentProvider(ABC):
|
|
|
217
217
|
)
|
|
218
218
|
|
|
219
219
|
total_gain_loss = total_value - total_cost_basis
|
|
220
|
+
# Use != 0 to handle short sales (negative cost basis)
|
|
220
221
|
total_gain_loss_percent = (
|
|
221
|
-
(total_gain_loss / total_cost_basis * 100) if total_cost_basis
|
|
222
|
+
(total_gain_loss / total_cost_basis * 100) if total_cost_basis != 0 else 0.0
|
|
222
223
|
)
|
|
223
224
|
|
|
224
225
|
return {
|
|
@@ -20,7 +20,6 @@ from ..models import (
|
|
|
20
20
|
InvestmentAccount,
|
|
21
21
|
InvestmentTransaction,
|
|
22
22
|
Security,
|
|
23
|
-
SecurityType,
|
|
24
23
|
TransactionType,
|
|
25
24
|
)
|
|
26
25
|
from .base import InvestmentProvider
|
|
@@ -96,6 +95,25 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
96
95
|
timeout=30.0,
|
|
97
96
|
)
|
|
98
97
|
|
|
98
|
+
def _auth_headers(self, user_id: str, user_secret: str) -> Dict[str, str]:
|
|
99
|
+
"""Build authentication headers for SnapTrade API requests.
|
|
100
|
+
|
|
101
|
+
SECURITY: User secrets are passed in headers, NOT URL params.
|
|
102
|
+
URL params are logged in access logs, browser history, and proxy logs.
|
|
103
|
+
Headers are not logged by default in most web servers.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
user_id: SnapTrade user ID
|
|
107
|
+
user_secret: SnapTrade user secret (sensitive!)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dict with authentication headers
|
|
111
|
+
"""
|
|
112
|
+
return {
|
|
113
|
+
"userId": user_id,
|
|
114
|
+
"userSecret": user_secret,
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
async def get_holdings(
|
|
100
118
|
self,
|
|
101
119
|
access_token: str,
|
|
@@ -123,12 +141,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
123
141
|
... print(f"{holding.security.ticker_symbol}: P&L ${pnl}")
|
|
124
142
|
"""
|
|
125
143
|
user_id, user_secret = self._parse_access_token(access_token)
|
|
144
|
+
auth_headers = self._auth_headers(user_id, user_secret)
|
|
126
145
|
|
|
127
146
|
try:
|
|
128
147
|
# Get all accounts
|
|
129
148
|
accounts_url = f"{self.base_url}/accounts"
|
|
130
|
-
|
|
131
|
-
response = await self.client.get(accounts_url, params=params)
|
|
149
|
+
response = await self.client.get(accounts_url, headers=auth_headers)
|
|
132
150
|
response.raise_for_status()
|
|
133
151
|
accounts = await response.json()
|
|
134
152
|
|
|
@@ -143,8 +161,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
143
161
|
positions_url = (
|
|
144
162
|
f"{self.base_url}/accounts/{account_id}/positions"
|
|
145
163
|
)
|
|
146
|
-
|
|
147
|
-
pos_response = await self.client.get(positions_url, params=pos_params)
|
|
164
|
+
pos_response = await self.client.get(positions_url, headers=auth_headers)
|
|
148
165
|
pos_response.raise_for_status()
|
|
149
166
|
positions = await pos_response.json()
|
|
150
167
|
|
|
@@ -192,12 +209,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
192
209
|
raise ValueError("start_date must be before end_date")
|
|
193
210
|
|
|
194
211
|
user_id, user_secret = self._parse_access_token(access_token)
|
|
212
|
+
auth_headers = self._auth_headers(user_id, user_secret)
|
|
195
213
|
|
|
196
214
|
try:
|
|
197
215
|
# Get all accounts
|
|
198
216
|
accounts_url = f"{self.base_url}/accounts"
|
|
199
|
-
|
|
200
|
-
response = await self.client.get(accounts_url, params=params)
|
|
217
|
+
response = await self.client.get(accounts_url, headers=auth_headers)
|
|
201
218
|
response.raise_for_status()
|
|
202
219
|
accounts = await response.json()
|
|
203
220
|
|
|
@@ -212,13 +229,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
212
229
|
transactions_url = (
|
|
213
230
|
f"{self.base_url}/accounts/{account_id}/transactions"
|
|
214
231
|
)
|
|
232
|
+
# Date params are non-sensitive, only auth goes in headers
|
|
215
233
|
tx_params = {
|
|
216
|
-
"userId": user_id,
|
|
217
|
-
"userSecret": user_secret,
|
|
218
234
|
"startDate": start_date.isoformat(),
|
|
219
235
|
"endDate": end_date.isoformat(),
|
|
220
236
|
}
|
|
221
|
-
tx_response = await self.client.get(transactions_url, params=tx_params)
|
|
237
|
+
tx_response = await self.client.get(transactions_url, params=tx_params, headers=auth_headers)
|
|
222
238
|
tx_response.raise_for_status()
|
|
223
239
|
transactions = await tx_response.json()
|
|
224
240
|
|
|
@@ -297,12 +313,12 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
297
313
|
... print(f" P&L: {account.total_unrealized_gain_loss_percent:.2f}%")
|
|
298
314
|
"""
|
|
299
315
|
user_id, user_secret = self._parse_access_token(access_token)
|
|
316
|
+
auth_headers = self._auth_headers(user_id, user_secret)
|
|
300
317
|
|
|
301
318
|
try:
|
|
302
319
|
# Get all accounts
|
|
303
320
|
accounts_url = f"{self.base_url}/accounts"
|
|
304
|
-
|
|
305
|
-
response = await self.client.get(accounts_url, params=params)
|
|
321
|
+
response = await self.client.get(accounts_url, headers=auth_headers)
|
|
306
322
|
response.raise_for_status()
|
|
307
323
|
accounts = await response.json()
|
|
308
324
|
|
|
@@ -315,8 +331,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
315
331
|
positions_url = (
|
|
316
332
|
f"{self.base_url}/accounts/{account_id}/positions"
|
|
317
333
|
)
|
|
318
|
-
|
|
319
|
-
pos_response = await self.client.get(positions_url, params=pos_params)
|
|
334
|
+
pos_response = await self.client.get(positions_url, headers=auth_headers)
|
|
320
335
|
pos_response.raise_for_status()
|
|
321
336
|
positions = await pos_response.json()
|
|
322
337
|
|
|
@@ -328,8 +343,7 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
328
343
|
|
|
329
344
|
# Get account balances
|
|
330
345
|
balances_url = f"{self.base_url}/accounts/{account_id}/balances"
|
|
331
|
-
|
|
332
|
-
bal_response = await self.client.get(balances_url, params=bal_params)
|
|
346
|
+
bal_response = await self.client.get(balances_url, headers=auth_headers)
|
|
333
347
|
bal_response.raise_for_status()
|
|
334
348
|
balances = await bal_response.json()
|
|
335
349
|
|
|
@@ -373,11 +387,11 @@ class SnapTradeInvestmentProvider(InvestmentProvider):
|
|
|
373
387
|
... print(f"Connected: {conn['brokerage_name']}")
|
|
374
388
|
"""
|
|
375
389
|
user_id, user_secret = self._parse_access_token(access_token)
|
|
390
|
+
auth_headers = self._auth_headers(user_id, user_secret)
|
|
376
391
|
|
|
377
392
|
try:
|
|
378
393
|
url = f"{self.base_url}/connections"
|
|
379
|
-
|
|
380
|
-
response = await self.client.get(url, params=params)
|
|
394
|
+
response = await self.client.get(url, headers=auth_headers)
|
|
381
395
|
response.raise_for_status()
|
|
382
396
|
return await response.json()
|
|
383
397
|
|
fin_infra/models/accounts.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from decimal import Decimal
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
|
-
from pydantic import BaseModel
|
|
7
|
+
from pydantic import BaseModel, field_validator
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class AccountType(str, Enum):
|
|
@@ -16,11 +17,26 @@ class AccountType(str, Enum):
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class Account(BaseModel):
|
|
20
|
+
"""Financial account model.
|
|
21
|
+
|
|
22
|
+
Uses Decimal for balance fields to prevent floating-point precision errors
|
|
23
|
+
in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
|
|
24
|
+
"""
|
|
19
25
|
id: str
|
|
20
26
|
name: str
|
|
21
27
|
type: AccountType
|
|
22
28
|
mask: Optional[str] = None
|
|
23
29
|
currency: str = "USD"
|
|
24
30
|
institution: Optional[str] = None
|
|
25
|
-
balance_available: Optional[
|
|
26
|
-
balance_current: Optional[
|
|
31
|
+
balance_available: Optional[Decimal] = None
|
|
32
|
+
balance_current: Optional[Decimal] = None
|
|
33
|
+
|
|
34
|
+
@field_validator("balance_available", "balance_current", mode="before")
|
|
35
|
+
@classmethod
|
|
36
|
+
def _coerce_balance_to_decimal(cls, v):
|
|
37
|
+
"""Coerce float/int to Decimal for backwards compatibility."""
|
|
38
|
+
if v is None:
|
|
39
|
+
return v
|
|
40
|
+
if isinstance(v, (int, float)):
|
|
41
|
+
return Decimal(str(v))
|
|
42
|
+
return v
|
fin_infra/models/transactions.py
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import date
|
|
4
|
+
from decimal import Decimal
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
|
-
from pydantic import BaseModel
|
|
7
|
+
from pydantic import BaseModel, field_validator
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Transaction(BaseModel):
|
|
11
|
+
"""Financial transaction model.
|
|
12
|
+
|
|
13
|
+
Uses Decimal for amount to prevent floating-point precision errors
|
|
14
|
+
in financial calculations (e.g., $0.01 + $0.02 != $0.03 with float).
|
|
15
|
+
"""
|
|
10
16
|
id: str
|
|
11
17
|
account_id: str
|
|
12
18
|
date: date
|
|
13
|
-
amount:
|
|
19
|
+
amount: Decimal
|
|
14
20
|
currency: str = "USD"
|
|
15
21
|
description: Optional[str] = None
|
|
16
22
|
category: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@field_validator("amount", mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _coerce_amount_to_decimal(cls, v):
|
|
27
|
+
"""Coerce float/int to Decimal for backwards compatibility."""
|
|
28
|
+
if isinstance(v, (int, float)):
|
|
29
|
+
return Decimal(str(v))
|
|
30
|
+
return v
|
|
@@ -100,18 +100,27 @@ def calculate_net_worth(
|
|
|
100
100
|
|
|
101
101
|
Returns:
|
|
102
102
|
Net worth in base currency
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If assets or liabilities contain non-base currencies and no
|
|
106
|
+
exchange rate conversion is available. This prevents silent data loss.
|
|
103
107
|
"""
|
|
108
|
+
import logging
|
|
109
|
+
logger = logging.getLogger(__name__)
|
|
110
|
+
|
|
111
|
+
# Collect any non-base currency items for error reporting
|
|
112
|
+
non_base_assets: list[tuple[str, str, float]] = []
|
|
113
|
+
non_base_liabilities: list[tuple[str, str, float]] = []
|
|
114
|
+
|
|
104
115
|
# Sum all assets (use market_value if available, otherwise balance)
|
|
105
116
|
total_assets = 0.0
|
|
106
117
|
for asset in assets:
|
|
107
118
|
# Use market value for investments/crypto (includes unrealized gains)
|
|
108
119
|
amount = asset.market_value if asset.market_value is not None else asset.balance
|
|
109
120
|
|
|
110
|
-
#
|
|
111
|
-
# For now, assume all are in USD
|
|
121
|
+
# Check for non-base currency
|
|
112
122
|
if asset.currency != base_currency:
|
|
113
|
-
|
|
114
|
-
# For V1, we'll require all accounts in USD or skip non-USD
|
|
123
|
+
non_base_assets.append((asset.name or asset.account_id, asset.currency, amount))
|
|
115
124
|
continue
|
|
116
125
|
|
|
117
126
|
total_assets += amount
|
|
@@ -119,13 +128,30 @@ def calculate_net_worth(
|
|
|
119
128
|
# Sum all liabilities
|
|
120
129
|
total_liabilities = 0.0
|
|
121
130
|
for liability in liabilities:
|
|
122
|
-
#
|
|
131
|
+
# Check for non-base currency
|
|
123
132
|
if liability.currency != base_currency:
|
|
124
|
-
|
|
133
|
+
non_base_liabilities.append((liability.name or liability.account_id, liability.currency, liability.balance))
|
|
125
134
|
continue
|
|
126
135
|
|
|
127
136
|
total_liabilities += liability.balance
|
|
128
137
|
|
|
138
|
+
# If any non-base currency items were found, log warning and raise error
|
|
139
|
+
# This prevents silent data loss where user's net worth is wrong
|
|
140
|
+
if non_base_assets or non_base_liabilities:
|
|
141
|
+
items_msg = []
|
|
142
|
+
if non_base_assets:
|
|
143
|
+
items_msg.append(f"Assets: {non_base_assets}")
|
|
144
|
+
if non_base_liabilities:
|
|
145
|
+
items_msg.append(f"Liabilities: {non_base_liabilities}")
|
|
146
|
+
|
|
147
|
+
error_msg = (
|
|
148
|
+
f"Cannot calculate net worth: found accounts in non-{base_currency} currencies. "
|
|
149
|
+
f"Currency conversion not yet implemented. {'; '.join(items_msg)}. "
|
|
150
|
+
f"Either convert all accounts to {base_currency} or wait for currency conversion feature."
|
|
151
|
+
)
|
|
152
|
+
logger.warning(error_msg)
|
|
153
|
+
raise ValueError(error_msg)
|
|
154
|
+
|
|
129
155
|
return total_assets - total_liabilities
|
|
130
156
|
|
|
131
157
|
|
|
@@ -54,12 +54,20 @@ class PlaidClient(BankingProvider):
|
|
|
54
54
|
|
|
55
55
|
# Map environment string to Plaid Environment enum
|
|
56
56
|
# Note: Plaid only has Sandbox and Production (no Development in SDK)
|
|
57
|
+
env_str = environment or "sandbox"
|
|
57
58
|
env_map = {
|
|
58
59
|
"sandbox": plaid.Environment.Sandbox,
|
|
59
|
-
"development": plaid.Environment.Sandbox, # Map development to sandbox
|
|
60
|
+
"development": plaid.Environment.Sandbox, # Map development to sandbox (Plaid SDK limitation)
|
|
60
61
|
"production": plaid.Environment.Production,
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
+
|
|
64
|
+
if env_str not in env_map:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Invalid Plaid environment: '{env_str}'. "
|
|
67
|
+
f"Must be one of: sandbox, development, production"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
host = env_map[env_str]
|
|
63
71
|
|
|
64
72
|
# Configure Plaid client (v8.0.0+ API)
|
|
65
73
|
configuration = plaid.Configuration(
|
fin_infra/providers/base.py
CHANGED
|
@@ -114,7 +114,27 @@ class TaxProvider(ABC):
|
|
|
114
114
|
class InvestmentProvider(ABC):
|
|
115
115
|
"""Provider for investment holdings and portfolio data (Plaid, SnapTrade).
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
This is a minimal ABC for type checking. The full implementation with
|
|
118
|
+
all abstract methods is in fin_infra.investments.providers.base.InvestmentProvider.
|
|
119
|
+
|
|
120
|
+
Abstract Methods (defined in full implementation):
|
|
121
|
+
- get_holdings(access_token, account_ids) -> List[Holding]
|
|
122
|
+
- get_transactions(access_token, start_date, end_date, account_ids) -> List[InvestmentTransaction]
|
|
123
|
+
- get_securities(access_token, security_ids) -> List[Security]
|
|
124
|
+
- get_investment_accounts(access_token) -> List[InvestmentAccount]
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
>>> from fin_infra.investments import easy_investments
|
|
128
|
+
>>> provider = easy_investments(provider="plaid")
|
|
129
|
+
>>> holdings = await provider.get_holdings(access_token)
|
|
119
130
|
"""
|
|
120
|
-
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
async def get_holdings(self, access_token: str, account_ids: list[str] | None = None) -> list:
|
|
134
|
+
"""Fetch holdings for investment accounts."""
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
async def get_investment_accounts(self, access_token: str) -> list:
|
|
139
|
+
"""Fetch investment accounts with aggregated holdings."""
|
|
140
|
+
pass
|
|
@@ -105,6 +105,9 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
105
105
|
) -> dict:
|
|
106
106
|
"""Submit an order to Alpaca.
|
|
107
107
|
|
|
108
|
+
IMPORTANT: client_order_id is auto-generated if not provided to ensure
|
|
109
|
+
idempotency. Network retries without idempotency can cause DOUBLE ORDERS.
|
|
110
|
+
|
|
108
111
|
Args:
|
|
109
112
|
symbol: Trading symbol (e.g., "AAPL")
|
|
110
113
|
qty: Order quantity
|
|
@@ -113,14 +116,20 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
113
116
|
time_in_force: "day", "gtc", "ioc", or "fok" (default: "day")
|
|
114
117
|
limit_price: Limit price (required for limit/stop_limit orders)
|
|
115
118
|
stop_price: Stop price (required for stop/stop_limit orders)
|
|
116
|
-
client_order_id:
|
|
119
|
+
client_order_id: Client order ID for idempotency. Auto-generated if not provided.
|
|
117
120
|
|
|
118
121
|
Returns:
|
|
119
|
-
Order dict with id, status, filled_qty, etc.
|
|
122
|
+
Order dict with id, status, filled_qty, client_order_id, etc.
|
|
120
123
|
|
|
121
124
|
Raises:
|
|
122
125
|
Exception: If order submission fails
|
|
123
126
|
"""
|
|
127
|
+
# CRITICAL: Auto-generate client_order_id for idempotency if not provided.
|
|
128
|
+
# Without this, network retries can cause duplicate order execution = MONEY LOSS.
|
|
129
|
+
if client_order_id is None:
|
|
130
|
+
import uuid
|
|
131
|
+
client_order_id = str(uuid.uuid4())
|
|
132
|
+
|
|
124
133
|
order = self.client.submit_order(
|
|
125
134
|
symbol=symbol,
|
|
126
135
|
qty=qty,
|
fin_infra/settings.py
CHANGED
|
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
|
|
12
12
|
# Plaid
|
|
13
13
|
plaid_client_id: str | None = Field(default=None, alias="PLAID_CLIENT_ID")
|
|
14
14
|
plaid_secret: str | None = Field(default=None, alias="PLAID_SECRET")
|
|
15
|
-
plaid_env: str = Field(default="sandbox", alias="
|
|
15
|
+
plaid_env: str = Field(default="sandbox", alias="PLAID_ENVIRONMENT")
|
|
16
16
|
|
|
17
17
|
# Alpaca
|
|
18
18
|
alpaca_api_key: str | None = Field(default=None, alias="ALPACA_API_KEY")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fin-infra
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.58
|
|
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
|
|
@@ -12,7 +12,7 @@ fin_infra/analytics/savings.py,sha256=tavIRZtu9FjCm-DeWg5f060GcsdgD-cl-vgKOnieOU
|
|
|
12
12
|
fin_infra/analytics/scenarios.py,sha256=LE_dZVkbxxAx5sxitGhiOhZfWTlYtVbIvS9pEXkijLc,12246
|
|
13
13
|
fin_infra/analytics/spending.py,sha256=ypgL52JOsneTsFa2_aFB9fVuu9QWQsImQYChtECeA4Y,25833
|
|
14
14
|
fin_infra/banking/__init__.py,sha256=wva1SEyrH2po79YycQ_00ZyC2tVeuO3uYcyvudOW484,22267
|
|
15
|
-
fin_infra/banking/history.py,sha256=
|
|
15
|
+
fin_infra/banking/history.py,sha256=1ufAwkTnXr-QJetFzJl4xA2e3dqd1-TkT8pf46MNfho,10630
|
|
16
16
|
fin_infra/banking/utils.py,sha256=B2ebnTeUz-56l8XMBWnf2txFOr0bXIo3cKPio7_bhc4,15711
|
|
17
17
|
fin_infra/brokerage/__init__.py,sha256=RB0wbVlxM9PCbWUezzjrOf19JucVDpCvNlT62LoMzho,17023
|
|
18
18
|
fin_infra/budgets/__init__.py,sha256=3VTYU_OdqblYiP5fjHHiw3m-FSj5trPz7XVTb3f3rBc,4106
|
|
@@ -50,10 +50,10 @@ fin_infra/compliance/__init__.py,sha256=03eXxRDFeQnEaz_W8MVYYDv_ni7Urue2StLf02jH
|
|
|
50
50
|
fin_infra/credit/__init__.py,sha256=cwCP_WlrG-0yb_L4zYsuzEsSalcfiCY9ItqXfD7Jx9E,6719
|
|
51
51
|
fin_infra/credit/add.py,sha256=etRbqw15vzUQfvnMTmznZlLiKy2GVEe8ok08Ea3pjdE,8490
|
|
52
52
|
fin_infra/credit/experian/__init__.py,sha256=g3IJGvDOMsnB0er0Uwdvl6hGKKTOazqJxSDnB2oIBm0,761
|
|
53
|
-
fin_infra/credit/experian/auth.py,sha256=
|
|
53
|
+
fin_infra/credit/experian/auth.py,sha256=SHi3YNPFwEAS_SraAiAK7V-DEokgaq-7-eqkkBrcgMo,5562
|
|
54
54
|
fin_infra/credit/experian/client.py,sha256=sxdoB9pyIntyZ9MKDN-x8tUuyllZSOq7KzOrHjeYs8s,8918
|
|
55
55
|
fin_infra/credit/experian/parser.py,sha256=7ptdLyTWWqHWqCo1CXn6L7XaIn9ZRRuOaATbFmMZZ64,7489
|
|
56
|
-
fin_infra/credit/experian/provider.py,sha256=
|
|
56
|
+
fin_infra/credit/experian/provider.py,sha256=iG2cyftdc7c2pvKWVfeNd3vF_ylNayyhgyUG7Jnl1VI,13766
|
|
57
57
|
fin_infra/credit/mock.py,sha256=xKWZk3fhuIYRfiZkNc9fbHUNViNKjmOLSj0MTI1f4ik,5356
|
|
58
58
|
fin_infra/crypto/__init__.py,sha256=HpplYEY8GiBz55ehYRDQxs8SWJIW1smBs9eFOKt_nzI,8318
|
|
59
59
|
fin_infra/crypto/insights.py,sha256=fuGFKkOkZoYKit29_kbH1T2XWvbMKVmhFUd57E2XOmQ,11695
|
|
@@ -80,12 +80,12 @@ fin_infra/insights/aggregator.py,sha256=XG32mN5w5Nc4AZllmfl1esL4q44mFAf0Fvj9mWev
|
|
|
80
80
|
fin_infra/insights/models.py,sha256=xov_YV8oBLJt3YdyVjbryRfcXqmGeGiPvZsZHSbvtl8,3202
|
|
81
81
|
fin_infra/investments/__init__.py,sha256=UiWvTdKH7V9aaqZLunPT1_QGfXBAZbPk_w4QmeLWLqo,6324
|
|
82
82
|
fin_infra/investments/add.py,sha256=XjIuXnGY1-tJWeDqsgTpbP2-ruh-Ulbu0IsSlyKRsqw,16416
|
|
83
|
-
fin_infra/investments/ease.py,sha256=
|
|
83
|
+
fin_infra/investments/ease.py,sha256=ocs7xvnZ1u8riFjH9KHi1yFEUF0lfuEcd-QMpsuiOu8,9229
|
|
84
84
|
fin_infra/investments/models.py,sha256=NHnkvtMa1QYp_TpuuqT4u3cWEJi3OhW-1e-orMuR47o,16107
|
|
85
85
|
fin_infra/investments/providers/__init__.py,sha256=V1eIzz6EnGJ-pq-9L3S2-evmcExF-YdZfd5P6JMyDtc,383
|
|
86
|
-
fin_infra/investments/providers/base.py,sha256=
|
|
86
|
+
fin_infra/investments/providers/base.py,sha256=KaJdIdeWi2WaWAogcFZw7jcqQ_IzMZw6misBNk-n6bE,9890
|
|
87
87
|
fin_infra/investments/providers/plaid.py,sha256=OKzLNPAcBiZlqBRCOeaBogB5fvHdvlRpPuGHKvRGS2E,18069
|
|
88
|
-
fin_infra/investments/providers/snaptrade.py,sha256=
|
|
88
|
+
fin_infra/investments/providers/snaptrade.py,sha256=IUgXoI7UCBXOAxn2J62cOiQSW02sujqEk47nb638264,23508
|
|
89
89
|
fin_infra/investments/scaffold_templates/README.md,sha256=PhgxfMLrro2Jz83b7XEnBi7lexiWKqlMrd2UU2Rbs8A,12149
|
|
90
90
|
fin_infra/investments/scaffold_templates/__init__.py,sha256=iR0oiAzXFYXHBnVJjaEnAzk6omncYOLg0TKMJ7xomBc,82
|
|
91
91
|
fin_infra/investments/scaffold_templates/models.py.tmpl,sha256=5inP5-jw-qEfPYxSN71tn4AojZ9PesOIeuHTw181N-c,5849
|
|
@@ -93,18 +93,18 @@ fin_infra/investments/scaffold_templates/repository.py.tmpl,sha256=XwOEpQZfuXut1
|
|
|
93
93
|
fin_infra/investments/scaffold_templates/schemas.py.tmpl,sha256=knWmn-Kyr7AdgPD4ZPMb6T49ZuPXeuOMqmjYNyA0CA0,5451
|
|
94
94
|
fin_infra/markets/__init__.py,sha256=mStcYiA4dq2yHEyStZyOLd-KkW-Jf657l8NSLLa_MU8,9512
|
|
95
95
|
fin_infra/models/__init__.py,sha256=q3SkGzDGFkoAMxwqJw8i4cHWt5NGU5ypjOgntxDGVKo,860
|
|
96
|
-
fin_infra/models/accounts.py,sha256=
|
|
96
|
+
fin_infra/models/accounts.py,sha256=PeobjGg6WM70OvOTe0JIo0zo7tBM0PDAcyClQT-Jo4o,1141
|
|
97
97
|
fin_infra/models/brokerage.py,sha256=z6Zyf0N5zmmXtrN2y_4fNmtIP5wNq40H8lrHLBwY7rc,8311
|
|
98
98
|
fin_infra/models/candle.py,sha256=7vrDxR1JFZodMUG8OGB0ft1_oaGW16gZtawjZ_2OwhA,535
|
|
99
99
|
fin_infra/models/credit.py,sha256=rSdSURsMe9_i2gxmwPTDwNQWOuM2zutL-OhvHsnbtmw,12144
|
|
100
100
|
fin_infra/models/money.py,sha256=5BX8IQZkrNtjjnGIQAK2tyKnVim0R-yc1F_EBxUhcr0,400
|
|
101
101
|
fin_infra/models/quotes.py,sha256=_2cDJS8_RLo4tLpJlqWd32J8uFNP0bbf1V_0u3NuLwo,543
|
|
102
102
|
fin_infra/models/tax.py,sha256=lhNVIW650CdtpfgmSyMMJdojV7QnpHOUFQKiwMLTT4A,15656
|
|
103
|
-
fin_infra/models/transactions.py,sha256=
|
|
103
|
+
fin_infra/models/transactions.py,sha256=zlu7Ath91ZmqQWKn8Bd_iV_XreWTDXFWJyfTTZ3J-ss,828
|
|
104
104
|
fin_infra/net_worth/__init__.py,sha256=EjEuHNg8gEfFwbfko1-o5j-gSUZ2FcO9h7l05C-zAJM,3101
|
|
105
105
|
fin_infra/net_worth/add.py,sha256=5xYy2L5hEEPiQNF79i-ArWVztLXk2XM97DoZYNWGAz8,23100
|
|
106
106
|
fin_infra/net_worth/aggregator.py,sha256=grif-N8qk77L_JQ4IlcOJaKKP1qpxel0lIV_ll3HgjI,12646
|
|
107
|
-
fin_infra/net_worth/calculator.py,sha256=
|
|
107
|
+
fin_infra/net_worth/calculator.py,sha256=JERDtZyFurw5x2NYqfHvJzv6qigamI3AFfR-wesTj_E,13133
|
|
108
108
|
fin_infra/net_worth/ease.py,sha256=wrQ5zI2nVsbSCVFQXBSEt0DMI6xK-jv5m0AyZkyuOx8,15106
|
|
109
109
|
fin_infra/net_worth/goals.py,sha256=BJGxdsMjvgQDELFEJo-ai3DvsAzUNXvzMXkwovHr8yQ,1238
|
|
110
110
|
fin_infra/net_worth/insights.py,sha256=4GUyV-YEXgBs0ZuBx1OnSV0B2N-e0jvKoIgQ2yB7M7g,25250
|
|
@@ -125,10 +125,10 @@ fin_infra/obs/__init__.py,sha256=kMMVl0fdwtJtZeKiusTuw0iO61Jo9-HNXsLmn3ffLRE,631
|
|
|
125
125
|
fin_infra/obs/classifier.py,sha256=6R2q-w71tk7WfXF5MBPqawxogcj6tILKZPlkpRZNDfg,5083
|
|
126
126
|
fin_infra/providers/__init__.py,sha256=jxhQm79T6DVXf7Wpy7luL-p50cE_IMUbjt4o3apzJQU,768
|
|
127
127
|
fin_infra/providers/banking/base.py,sha256=KeNU4ur3zLKHVsBF1LQifcs2AKX06IEE-Rx_SetFeAs,102
|
|
128
|
-
fin_infra/providers/banking/plaid_client.py,sha256=
|
|
128
|
+
fin_infra/providers/banking/plaid_client.py,sha256=t4pW6vkecx1AgkKDXUSiileAxA6pu6dA4L6c8zSff0k,6545
|
|
129
129
|
fin_infra/providers/banking/teller_client.py,sha256=r4apwTNt8FJ2Rn2bC97orzaVYFkE0yMXXl--H5rtph0,9800
|
|
130
|
-
fin_infra/providers/base.py,sha256=
|
|
131
|
-
fin_infra/providers/brokerage/alpaca.py,sha256=
|
|
130
|
+
fin_infra/providers/base.py,sha256=oLzdExPGE7yg-URtin3vGTQ8hEzG7UnTmDGDWJB5oL0,4273
|
|
131
|
+
fin_infra/providers/brokerage/alpaca.py,sha256=eOzdRp45_VeD1r_2k0IudxFn0IRhGogn-htF5IJVKnk,9862
|
|
132
132
|
fin_infra/providers/brokerage/base.py,sha256=JJFH0Cqca4Rg4rmxfiwcQt-peRoBf4JpG3g6jx8DVks,106
|
|
133
133
|
fin_infra/providers/credit/experian.py,sha256=hNEVqmCaPT72NHV3Nw3sKOYPX0kIsl819ucqUc-7z2k,341
|
|
134
134
|
fin_infra/providers/identity/stripe_identity.py,sha256=JQGJRuQdWP5dWDcROgtz1RrmpkytRv95H6Fn-x1kifU,501
|
|
@@ -164,7 +164,7 @@ fin_infra/security/models.py,sha256=riQO-083p5rDMRrFxRnc2PTkxkAf-HsSpGvrnzboCNE,
|
|
|
164
164
|
fin_infra/security/pii_filter.py,sha256=lfARBmPRekkyXKJV0tWI_0KVaDsdV61VH-8RHxvbqUs,8307
|
|
165
165
|
fin_infra/security/pii_patterns.py,sha256=hsW-2RwA8XW3wprsvzkqcWK9uX_HrdLH53g7OUKiwvM,3046
|
|
166
166
|
fin_infra/security/token_store.py,sha256=lNnGUlQCJImc-OAcHbHij8xYHkV3jb6MgBpwEra-T_M,5862
|
|
167
|
-
fin_infra/settings.py,sha256=
|
|
167
|
+
fin_infra/settings.py,sha256=xitpBQJmuvSy9prQhvXOW1scbwB1KAyGD8XqYgU_hQU,1388
|
|
168
168
|
fin_infra/tax/__init__.py,sha256=NXUjV-k-rw4774pookY3UOwEXYRQauJze6Yift5RjW0,6107
|
|
169
169
|
fin_infra/tax/add.py,sha256=xmy0hXsWzEj5p-_9A5hkljFjF_FpnbCQQZ5e8FPChBI,14568
|
|
170
170
|
fin_infra/tax/tlh.py,sha256=QFnepLuJW8L71SccRTE54eN5ILyDGs1XNG5p-aVM_b8,21543
|
|
@@ -173,8 +173,8 @@ fin_infra/utils/http.py,sha256=wgXo5amXyzAX49v_lRUvp4Xxq8nodX32CMJyWl6u89I,568
|
|
|
173
173
|
fin_infra/utils/retry.py,sha256=VxT4ssP4r8Krl3KThvI-opPMhGCpZUCH4rUyit1LEUk,967
|
|
174
174
|
fin_infra/utils.py,sha256=VxT4ssP4r8Krl3KThvI-opPMhGCpZUCH4rUyit1LEUk,967
|
|
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.58.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
|
|
177
|
+
fin_infra-0.1.58.dist-info/METADATA,sha256=YnC_rmHC_X1zvZu2BKMVDXDKABUbI5GeODWLWnLXSqQ,10182
|
|
178
|
+
fin_infra-0.1.58.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
179
|
+
fin_infra-0.1.58.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
|
|
180
|
+
fin_infra-0.1.58.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|