fin-infra 0.1.62__py3-none-any.whl → 0.1.82__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +30 -32
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +19 -26
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +33 -31
- fin_infra/banking/history.py +11 -12
- fin_infra/banking/utils.py +116 -110
- fin_infra/brokerage/__init__.py +27 -27
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +3 -3
- fin_infra/budgets/tracker.py +4 -5
- fin_infra/cashflows/__init__.py +8 -10
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +17 -19
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +21 -18
- fin_infra/categorization/llm_layer.py +10 -10
- fin_infra/categorization/models.py +1 -1
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +13 -22
- fin_infra/chat/planning.py +57 -1
- fin_infra/cli/cmds/scaffold_cmds.py +11 -12
- fin_infra/clients/__init__.py +23 -1
- fin_infra/clients/base.py +1 -1
- fin_infra/clients/plaid.py +2 -2
- fin_infra/compliance/__init__.py +7 -6
- fin_infra/credit/add.py +7 -7
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +19 -19
- fin_infra/crypto/__init__.py +8 -10
- fin_infra/crypto/insights.py +5 -6
- fin_infra/documents/add.py +11 -13
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +18 -17
- fin_infra/documents/models.py +7 -7
- fin_infra/documents/ocr.py +8 -8
- fin_infra/documents/storage.py +23 -14
- fin_infra/exceptions.py +1 -2
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +36 -36
- fin_infra/goals/funding.py +4 -6
- fin_infra/goals/management.py +6 -7
- fin_infra/goals/milestones.py +2 -3
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +12 -10
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +14 -9
- fin_infra/investments/add.py +53 -73
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +135 -69
- fin_infra/investments/providers/base.py +9 -15
- fin_infra/investments/providers/plaid.py +70 -55
- fin_infra/investments/providers/snaptrade.py +35 -53
- fin_infra/markets/__init__.py +16 -11
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +4 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +9 -6
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -5
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +17 -15
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +23 -22
- fin_infra/providers/banking/teller_client.py +14 -7
- fin_infra/providers/base.py +131 -14
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +25 -4
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +23 -8
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +8 -8
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +24 -12
- fin_infra/recurring/detector.py +8 -8
- fin_infra/recurring/detectors_llm.py +14 -13
- fin_infra/recurring/ease.py +3 -5
- fin_infra/recurring/insights.py +20 -19
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +11 -10
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +5 -5
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +2 -2
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +5 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.62.dist-info/RECORD +0 -180
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
fin_infra/net_worth/ease.py
CHANGED
|
@@ -121,6 +121,15 @@ class NetWorthTracker:
|
|
|
121
121
|
self.goal_tracker = goal_tracker
|
|
122
122
|
self.conversation = conversation
|
|
123
123
|
|
|
124
|
+
# Configuration set by easy_net_worth(); declared here for type checkers.
|
|
125
|
+
self.snapshot_schedule: str = "daily"
|
|
126
|
+
self.change_threshold_percent: float = 5.0
|
|
127
|
+
self.change_threshold_amount: float = 10000.0
|
|
128
|
+
self.enable_llm: bool = False
|
|
129
|
+
self.llm_provider: str | None = None
|
|
130
|
+
self.llm_model: str | None = None
|
|
131
|
+
self.config: dict[str, Any] = {}
|
|
132
|
+
|
|
124
133
|
async def calculate_net_worth(
|
|
125
134
|
self,
|
|
126
135
|
user_id: str,
|
|
@@ -368,12 +377,20 @@ def easy_net_worth(
|
|
|
368
377
|
|
|
369
378
|
if enable_llm:
|
|
370
379
|
try:
|
|
371
|
-
from ai_infra.llm import LLM
|
|
380
|
+
from ai_infra.llm.llm import LLM
|
|
372
381
|
except ImportError:
|
|
373
382
|
raise ImportError(
|
|
374
|
-
"LLM features require ai-infra package.
|
|
383
|
+
"LLM features require ai-infra package. Install with: pip install ai-infra"
|
|
375
384
|
)
|
|
376
385
|
|
|
386
|
+
cache = None
|
|
387
|
+
try:
|
|
388
|
+
from svc_infra.cache import get_cache
|
|
389
|
+
|
|
390
|
+
cache = get_cache()
|
|
391
|
+
except Exception:
|
|
392
|
+
cache = None
|
|
393
|
+
|
|
377
394
|
# Determine default model
|
|
378
395
|
default_models = {
|
|
379
396
|
"google": "gemini-2.0-flash-exp",
|
|
@@ -384,7 +401,7 @@ def easy_net_worth(
|
|
|
384
401
|
|
|
385
402
|
if not model_name:
|
|
386
403
|
raise ValueError(
|
|
387
|
-
f"Unknown llm_provider: {llm_provider}.
|
|
404
|
+
f"Unknown llm_provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'"
|
|
388
405
|
)
|
|
389
406
|
|
|
390
407
|
# Create shared LLM instance
|
|
@@ -416,18 +433,22 @@ def easy_net_worth(
|
|
|
416
433
|
# goals.management not yet implemented, skip
|
|
417
434
|
pass
|
|
418
435
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
436
|
+
if cache is not None:
|
|
437
|
+
try:
|
|
438
|
+
from fin_infra.conversation import FinancialPlanningConversation
|
|
439
|
+
|
|
440
|
+
conversation = FinancialPlanningConversation(
|
|
441
|
+
llm=llm,
|
|
442
|
+
cache=cache, # Required for context storage
|
|
443
|
+
provider=llm_provider,
|
|
444
|
+
model_name=model_name,
|
|
445
|
+
)
|
|
446
|
+
except ImportError:
|
|
447
|
+
# conversation module not yet implemented, skip
|
|
448
|
+
pass
|
|
449
|
+
except Exception:
|
|
450
|
+
# Cache not configured or other runtime issue; skip optional conversation wiring.
|
|
451
|
+
pass
|
|
431
452
|
|
|
432
453
|
# Create tracker
|
|
433
454
|
tracker = NetWorthTracker(
|
fin_infra/net_worth/insights.py
CHANGED
|
@@ -31,7 +31,6 @@ from typing import Any
|
|
|
31
31
|
|
|
32
32
|
from pydantic import BaseModel, Field
|
|
33
33
|
|
|
34
|
-
|
|
35
34
|
# ============================================================================
|
|
36
35
|
# Pydantic Schemas (Structured Output)
|
|
37
36
|
# ============================================================================
|
|
@@ -479,13 +478,13 @@ class NetWorthInsightsGenerator:
|
|
|
479
478
|
|
|
480
479
|
user_prompt = f"""Analyze wealth trends:
|
|
481
480
|
|
|
482
|
-
Current net worth: ${current[
|
|
483
|
-
Previous net worth: ${previous[
|
|
481
|
+
Current net worth: ${current["total_net_worth"]:,.0f}
|
|
482
|
+
Previous net worth: ${previous["total_net_worth"]:,.0f}
|
|
484
483
|
Period: {period}
|
|
485
484
|
Change: ${change_amount:,.0f} ({change_percent:.1%})
|
|
486
485
|
|
|
487
|
-
Assets: ${current[
|
|
488
|
-
Liabilities: ${current[
|
|
486
|
+
Assets: ${current["total_assets"]:,.0f}
|
|
487
|
+
Liabilities: ${current["total_liabilities"]:,.0f}
|
|
489
488
|
|
|
490
489
|
Identify drivers of change, risk factors, and recommendations."""
|
|
491
490
|
|
fin_infra/net_worth/models.py
CHANGED
|
@@ -19,6 +19,7 @@ Pydantic V2 models for net worth tracking.
|
|
|
19
19
|
|
|
20
20
|
from datetime import datetime
|
|
21
21
|
from enum import Enum
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
22
23
|
|
|
23
24
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
24
25
|
|
|
@@ -207,54 +208,100 @@ class AssetAllocation(BaseModel):
|
|
|
207
208
|
vehicles: float = Field(0.0, ge=0, description="Vehicle value")
|
|
208
209
|
other_assets: float = Field(0.0, ge=0, description="Other asset value")
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
211
|
+
if TYPE_CHECKING:
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def total_assets(self) -> float:
|
|
215
|
+
"""Sum of all asset categories."""
|
|
216
|
+
return (
|
|
217
|
+
self.cash
|
|
218
|
+
+ self.investments
|
|
219
|
+
+ self.crypto
|
|
220
|
+
+ self.real_estate
|
|
221
|
+
+ self.vehicles
|
|
222
|
+
+ self.other_assets
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def cash_percentage(self) -> float:
|
|
227
|
+
"""Cash as percentage of total assets."""
|
|
228
|
+
return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def investments_percentage(self) -> float:
|
|
232
|
+
"""Investments as percentage of total assets."""
|
|
233
|
+
return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def crypto_percentage(self) -> float:
|
|
237
|
+
"""Crypto as percentage of total assets."""
|
|
238
|
+
return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def real_estate_percentage(self) -> float:
|
|
242
|
+
"""Real estate as percentage of total assets."""
|
|
243
|
+
return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def vehicles_percentage(self) -> float:
|
|
247
|
+
"""Vehicles as percentage of total assets."""
|
|
248
|
+
return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def other_percentage(self) -> float:
|
|
252
|
+
"""Other assets as percentage of total assets."""
|
|
253
|
+
return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
254
|
+
|
|
255
|
+
else:
|
|
256
|
+
|
|
257
|
+
@computed_field
|
|
258
|
+
@property
|
|
259
|
+
def total_assets(self) -> float:
|
|
260
|
+
"""Sum of all asset categories."""
|
|
261
|
+
return (
|
|
262
|
+
self.cash
|
|
263
|
+
+ self.investments
|
|
264
|
+
+ self.crypto
|
|
265
|
+
+ self.real_estate
|
|
266
|
+
+ self.vehicles
|
|
267
|
+
+ self.other_assets
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@computed_field
|
|
271
|
+
@property
|
|
272
|
+
def cash_percentage(self) -> float:
|
|
273
|
+
"""Cash as percentage of total assets."""
|
|
274
|
+
return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
275
|
+
|
|
276
|
+
@computed_field
|
|
277
|
+
@property
|
|
278
|
+
def investments_percentage(self) -> float:
|
|
279
|
+
"""Investments as percentage of total assets."""
|
|
280
|
+
return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
281
|
+
|
|
282
|
+
@computed_field
|
|
283
|
+
@property
|
|
284
|
+
def crypto_percentage(self) -> float:
|
|
285
|
+
"""Crypto as percentage of total assets."""
|
|
286
|
+
return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
287
|
+
|
|
288
|
+
@computed_field
|
|
289
|
+
@property
|
|
290
|
+
def real_estate_percentage(self) -> float:
|
|
291
|
+
"""Real estate as percentage of total assets."""
|
|
292
|
+
return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
293
|
+
|
|
294
|
+
@computed_field
|
|
295
|
+
@property
|
|
296
|
+
def vehicles_percentage(self) -> float:
|
|
297
|
+
"""Vehicles as percentage of total assets."""
|
|
298
|
+
return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
299
|
+
|
|
300
|
+
@computed_field
|
|
301
|
+
@property
|
|
302
|
+
def other_percentage(self) -> float:
|
|
303
|
+
"""Other assets as percentage of total assets."""
|
|
304
|
+
return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
|
|
258
305
|
|
|
259
306
|
|
|
260
307
|
class LiabilityBreakdown(BaseModel):
|
|
@@ -288,74 +335,148 @@ class LiabilityBreakdown(BaseModel):
|
|
|
288
335
|
personal_loans: float = Field(0.0, ge=0, description="Personal loan balance")
|
|
289
336
|
lines_of_credit: float = Field(0.0, ge=0, description="Line of credit balance")
|
|
290
337
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
338
|
+
if TYPE_CHECKING:
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def total_liabilities(self) -> float:
|
|
342
|
+
"""Sum of all liability categories."""
|
|
343
|
+
return (
|
|
344
|
+
self.credit_cards
|
|
345
|
+
+ self.mortgages
|
|
346
|
+
+ self.auto_loans
|
|
347
|
+
+ self.student_loans
|
|
348
|
+
+ self.personal_loans
|
|
349
|
+
+ self.lines_of_credit
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def credit_cards_percentage(self) -> float:
|
|
354
|
+
"""Credit cards as percentage of total liabilities."""
|
|
355
|
+
return (
|
|
356
|
+
(self.credit_cards / self.total_liabilities * 100)
|
|
357
|
+
if self.total_liabilities > 0
|
|
358
|
+
else 0.0
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def mortgages_percentage(self) -> float:
|
|
363
|
+
"""Mortgages as percentage of total liabilities."""
|
|
364
|
+
return (
|
|
365
|
+
(self.mortgages / self.total_liabilities * 100)
|
|
366
|
+
if self.total_liabilities > 0
|
|
367
|
+
else 0.0
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def auto_loans_percentage(self) -> float:
|
|
372
|
+
"""Auto loans as percentage of total liabilities."""
|
|
373
|
+
return (
|
|
374
|
+
(self.auto_loans / self.total_liabilities * 100)
|
|
375
|
+
if self.total_liabilities > 0
|
|
376
|
+
else 0.0
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def student_loans_percentage(self) -> float:
|
|
381
|
+
"""Student loans as percentage of total liabilities."""
|
|
382
|
+
return (
|
|
383
|
+
(self.student_loans / self.total_liabilities * 100)
|
|
384
|
+
if self.total_liabilities > 0
|
|
385
|
+
else 0.0
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def personal_loans_percentage(self) -> float:
|
|
390
|
+
"""Personal loans as percentage of total liabilities."""
|
|
391
|
+
return (
|
|
392
|
+
(self.personal_loans / self.total_liabilities * 100)
|
|
393
|
+
if self.total_liabilities > 0
|
|
394
|
+
else 0.0
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def lines_of_credit_percentage(self) -> float:
|
|
399
|
+
"""Lines of credit as percentage of total liabilities."""
|
|
400
|
+
return (
|
|
401
|
+
(self.lines_of_credit / self.total_liabilities * 100)
|
|
402
|
+
if self.total_liabilities > 0
|
|
403
|
+
else 0.0
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
else:
|
|
407
|
+
|
|
408
|
+
@computed_field
|
|
409
|
+
@property
|
|
410
|
+
def total_liabilities(self) -> float:
|
|
411
|
+
"""Sum of all liability categories."""
|
|
412
|
+
return (
|
|
413
|
+
self.credit_cards
|
|
414
|
+
+ self.mortgages
|
|
415
|
+
+ self.auto_loans
|
|
416
|
+
+ self.student_loans
|
|
417
|
+
+ self.personal_loans
|
|
418
|
+
+ self.lines_of_credit
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
@computed_field
|
|
422
|
+
@property
|
|
423
|
+
def credit_cards_percentage(self) -> float:
|
|
424
|
+
"""Credit cards as percentage of total liabilities."""
|
|
425
|
+
return (
|
|
426
|
+
(self.credit_cards / self.total_liabilities * 100)
|
|
427
|
+
if self.total_liabilities > 0
|
|
428
|
+
else 0.0
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
@computed_field
|
|
432
|
+
@property
|
|
433
|
+
def mortgages_percentage(self) -> float:
|
|
434
|
+
"""Mortgages as percentage of total liabilities."""
|
|
435
|
+
return (
|
|
436
|
+
(self.mortgages / self.total_liabilities * 100)
|
|
437
|
+
if self.total_liabilities > 0
|
|
438
|
+
else 0.0
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
@computed_field
|
|
442
|
+
@property
|
|
443
|
+
def auto_loans_percentage(self) -> float:
|
|
444
|
+
"""Auto loans as percentage of total liabilities."""
|
|
445
|
+
return (
|
|
446
|
+
(self.auto_loans / self.total_liabilities * 100)
|
|
447
|
+
if self.total_liabilities > 0
|
|
448
|
+
else 0.0
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@computed_field
|
|
452
|
+
@property
|
|
453
|
+
def student_loans_percentage(self) -> float:
|
|
454
|
+
"""Student loans as percentage of total liabilities."""
|
|
455
|
+
return (
|
|
456
|
+
(self.student_loans / self.total_liabilities * 100)
|
|
457
|
+
if self.total_liabilities > 0
|
|
458
|
+
else 0.0
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
@computed_field
|
|
462
|
+
@property
|
|
463
|
+
def personal_loans_percentage(self) -> float:
|
|
464
|
+
"""Personal loans as percentage of total liabilities."""
|
|
465
|
+
return (
|
|
466
|
+
(self.personal_loans / self.total_liabilities * 100)
|
|
467
|
+
if self.total_liabilities > 0
|
|
468
|
+
else 0.0
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@computed_field
|
|
472
|
+
@property
|
|
473
|
+
def lines_of_credit_percentage(self) -> float:
|
|
474
|
+
"""Lines of credit as percentage of total liabilities."""
|
|
475
|
+
return (
|
|
476
|
+
(self.lines_of_credit / self.total_liabilities * 100)
|
|
477
|
+
if self.total_liabilities > 0
|
|
478
|
+
else 0.0
|
|
479
|
+
)
|
|
359
480
|
|
|
360
481
|
|
|
361
482
|
class AssetDetail(BaseModel):
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"""Data normalization module for financial symbols and currencies."""
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
3
8
|
from fin_infra.normalization.currency_converter import (
|
|
4
9
|
CurrencyConverter,
|
|
5
10
|
CurrencyNotSupportedError,
|
|
@@ -64,7 +69,7 @@ def easy_normalization(
|
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
def add_normalization(
|
|
67
|
-
app: "FastAPI",
|
|
72
|
+
app: "FastAPI",
|
|
68
73
|
*,
|
|
69
74
|
prefix: str = "/normalize",
|
|
70
75
|
api_key: str | None = None,
|
|
@@ -110,17 +115,12 @@ def add_normalization(
|
|
|
110
115
|
- Integrated with svc-infra observability (request metrics)
|
|
111
116
|
- Scoped docs at {prefix}/docs for standalone documentation
|
|
112
117
|
"""
|
|
113
|
-
from typing import TYPE_CHECKING
|
|
114
|
-
|
|
115
|
-
if TYPE_CHECKING:
|
|
116
|
-
from fastapi import FastAPI
|
|
117
|
-
|
|
118
118
|
# Import FastAPI dependencies
|
|
119
|
-
from fastapi import
|
|
119
|
+
from fastapi import HTTPException, Query
|
|
120
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
120
121
|
|
|
121
122
|
# Import svc-infra public router (no auth - utility endpoints)
|
|
122
123
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
123
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
124
124
|
|
|
125
125
|
# Get normalization services
|
|
126
126
|
resolver, converter = easy_normalization(api_key=api_key)
|
|
@@ -145,19 +145,21 @@ def add_normalization(
|
|
|
145
145
|
@router.get("/convert")
|
|
146
146
|
async def convert_currency(
|
|
147
147
|
amount: float = Query(..., description="Amount to convert"),
|
|
148
|
-
from_currency: str = Query(
|
|
148
|
+
from_currency: str = Query(
|
|
149
|
+
..., alias="from", description="Source currency code (e.g., USD)"
|
|
150
|
+
),
|
|
149
151
|
to_currency: str = Query(..., alias="to", description="Target currency code (e.g., EUR)"),
|
|
150
152
|
):
|
|
151
153
|
"""Convert amount between currencies."""
|
|
152
154
|
try:
|
|
153
|
-
result = await converter.
|
|
155
|
+
result = await converter.convert_with_details(amount, from_currency, to_currency)
|
|
154
156
|
return {
|
|
155
|
-
"amount": amount,
|
|
156
|
-
"from_currency": from_currency,
|
|
157
|
-
"to_currency": to_currency,
|
|
158
|
-
"result": result.
|
|
157
|
+
"amount": result.amount,
|
|
158
|
+
"from_currency": result.from_currency,
|
|
159
|
+
"to_currency": result.to_currency,
|
|
160
|
+
"result": result.converted,
|
|
159
161
|
"rate": result.rate,
|
|
160
|
-
"timestamp": result.
|
|
162
|
+
"timestamp": result.date.isoformat() if result.date else None,
|
|
161
163
|
}
|
|
162
164
|
except CurrencyNotSupportedError as e:
|
|
163
165
|
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from datetime import date as DateType
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import cast
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ __all__ = [
|
|
|
19
19
|
class ExchangeRateClient:
|
|
20
20
|
"""Client for exchangerate-api.io API."""
|
|
21
21
|
|
|
22
|
-
def __init__(self, api_key:
|
|
22
|
+
def __init__(self, api_key: str | None = None):
|
|
23
23
|
"""
|
|
24
24
|
Initialize exchange rate client.
|
|
25
25
|
|
|
@@ -66,10 +66,10 @@ class ExchangeRateClient:
|
|
|
66
66
|
raise ExchangeRateAPIError(
|
|
67
67
|
f"API returned error: {data.get('error-type', 'unknown')}"
|
|
68
68
|
)
|
|
69
|
-
return data["conversion_rates"]
|
|
69
|
+
return cast("dict[str, float]", data["conversion_rates"])
|
|
70
70
|
else:
|
|
71
71
|
# Free tier response format
|
|
72
|
-
return data["rates"]
|
|
72
|
+
return cast("dict[str, float]", data["rates"])
|
|
73
73
|
|
|
74
74
|
except httpx.HTTPError as e:
|
|
75
75
|
raise ExchangeRateAPIError(f"HTTP error fetching rates: {e}")
|
|
@@ -77,7 +77,7 @@ class ExchangeRateClient:
|
|
|
77
77
|
raise ExchangeRateAPIError(f"Invalid API response: {e}")
|
|
78
78
|
|
|
79
79
|
async def get_rate(
|
|
80
|
-
self, from_currency: str, to_currency: str, date:
|
|
80
|
+
self, from_currency: str, to_currency: str, date: DateType | None = None
|
|
81
81
|
) -> ExchangeRate:
|
|
82
82
|
"""
|
|
83
83
|
Get exchange rate between two currencies.
|
fin_infra/obs/classifier.py
CHANGED
|
@@ -37,7 +37,7 @@ Usage:
|
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
|
-
from
|
|
40
|
+
from collections.abc import Callable
|
|
41
41
|
|
|
42
42
|
# Financial capability prefix patterns (extensible)
|
|
43
43
|
FINANCIAL_ROUTE_PREFIXES = (
|
|
@@ -112,9 +112,9 @@ def financial_route_classifier(route_path: str, method: str) -> str:
|
|
|
112
112
|
|
|
113
113
|
|
|
114
114
|
def compose_classifiers(
|
|
115
|
-
*classifiers: Callable[[str], str],
|
|
115
|
+
*classifiers: Callable[[str, str], str],
|
|
116
116
|
default: str = "public",
|
|
117
|
-
) -> Callable[[str], str]:
|
|
117
|
+
) -> Callable[[str, str], str]:
|
|
118
118
|
"""
|
|
119
119
|
Compose multiple route classifiers with fallback logic.
|
|
120
120
|
|