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.
Files changed (126) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +30 -32
  4. fin_infra/analytics/cash_flow.py +6 -5
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/portfolio.py +19 -26
  7. fin_infra/analytics/projections.py +1 -3
  8. fin_infra/analytics/rebalancing.py +2 -4
  9. fin_infra/analytics/savings.py +1 -1
  10. fin_infra/analytics/spending.py +15 -11
  11. fin_infra/banking/__init__.py +33 -31
  12. fin_infra/banking/history.py +11 -12
  13. fin_infra/banking/utils.py +116 -110
  14. fin_infra/brokerage/__init__.py +27 -27
  15. fin_infra/budgets/__init__.py +3 -3
  16. fin_infra/budgets/add.py +16 -17
  17. fin_infra/budgets/alerts.py +3 -3
  18. fin_infra/budgets/tracker.py +4 -5
  19. fin_infra/cashflows/__init__.py +8 -10
  20. fin_infra/cashflows/core.py +1 -1
  21. fin_infra/categorization/__init__.py +1 -1
  22. fin_infra/categorization/add.py +17 -19
  23. fin_infra/categorization/ease.py +3 -4
  24. fin_infra/categorization/engine.py +21 -18
  25. fin_infra/categorization/llm_layer.py +10 -10
  26. fin_infra/categorization/models.py +1 -1
  27. fin_infra/categorization/rules.py +2 -4
  28. fin_infra/categorization/taxonomy.py +2 -2
  29. fin_infra/chat/__init__.py +13 -22
  30. fin_infra/chat/planning.py +57 -1
  31. fin_infra/cli/cmds/scaffold_cmds.py +11 -12
  32. fin_infra/clients/__init__.py +23 -1
  33. fin_infra/clients/base.py +1 -1
  34. fin_infra/clients/plaid.py +2 -2
  35. fin_infra/compliance/__init__.py +7 -6
  36. fin_infra/credit/add.py +7 -7
  37. fin_infra/credit/experian/auth.py +3 -2
  38. fin_infra/credit/experian/client.py +2 -2
  39. fin_infra/credit/experian/provider.py +19 -19
  40. fin_infra/crypto/__init__.py +8 -10
  41. fin_infra/crypto/insights.py +5 -6
  42. fin_infra/documents/add.py +11 -13
  43. fin_infra/documents/analysis.py +9 -9
  44. fin_infra/documents/ease.py +18 -17
  45. fin_infra/documents/models.py +7 -7
  46. fin_infra/documents/ocr.py +8 -8
  47. fin_infra/documents/storage.py +23 -14
  48. fin_infra/exceptions.py +1 -2
  49. fin_infra/goals/__init__.py +8 -8
  50. fin_infra/goals/add.py +36 -36
  51. fin_infra/goals/funding.py +4 -6
  52. fin_infra/goals/management.py +6 -7
  53. fin_infra/goals/milestones.py +2 -3
  54. fin_infra/goals/models.py +7 -11
  55. fin_infra/insights/__init__.py +12 -10
  56. fin_infra/insights/aggregator.py +1 -1
  57. fin_infra/investments/__init__.py +14 -9
  58. fin_infra/investments/add.py +53 -73
  59. fin_infra/investments/ease.py +16 -13
  60. fin_infra/investments/models.py +135 -69
  61. fin_infra/investments/providers/base.py +9 -15
  62. fin_infra/investments/providers/plaid.py +70 -55
  63. fin_infra/investments/providers/snaptrade.py +35 -53
  64. fin_infra/markets/__init__.py +16 -11
  65. fin_infra/models/__init__.py +10 -10
  66. fin_infra/models/accounts.py +2 -1
  67. fin_infra/models/brokerage.py +2 -1
  68. fin_infra/models/candle.py +1 -0
  69. fin_infra/models/money.py +1 -0
  70. fin_infra/models/quotes.py +4 -3
  71. fin_infra/models/tax.py +2 -1
  72. fin_infra/models/transactions.py +4 -4
  73. fin_infra/net_worth/__init__.py +7 -0
  74. fin_infra/net_worth/add.py +8 -5
  75. fin_infra/net_worth/aggregator.py +9 -6
  76. fin_infra/net_worth/calculator.py +8 -6
  77. fin_infra/net_worth/ease.py +36 -15
  78. fin_infra/net_worth/insights.py +4 -5
  79. fin_infra/net_worth/models.py +237 -116
  80. fin_infra/normalization/__init__.py +17 -15
  81. fin_infra/normalization/providers/exchangerate.py +5 -5
  82. fin_infra/obs/classifier.py +3 -3
  83. fin_infra/providers/banking/plaid_client.py +23 -22
  84. fin_infra/providers/banking/teller_client.py +14 -7
  85. fin_infra/providers/base.py +131 -14
  86. fin_infra/providers/brokerage/alpaca.py +7 -7
  87. fin_infra/providers/credit/experian.py +5 -0
  88. fin_infra/providers/market/alphavantage.py +6 -11
  89. fin_infra/providers/market/ccxt_crypto.py +25 -4
  90. fin_infra/providers/market/coingecko.py +5 -6
  91. fin_infra/providers/market/yahoo.py +23 -8
  92. fin_infra/providers/tax/__init__.py +1 -1
  93. fin_infra/providers/tax/irs.py +1 -1
  94. fin_infra/providers/tax/mock.py +8 -8
  95. fin_infra/providers/tax/taxbit.py +1 -1
  96. fin_infra/recurring/__init__.py +6 -6
  97. fin_infra/recurring/add.py +24 -12
  98. fin_infra/recurring/detector.py +8 -8
  99. fin_infra/recurring/detectors_llm.py +14 -13
  100. fin_infra/recurring/ease.py +3 -5
  101. fin_infra/recurring/insights.py +20 -19
  102. fin_infra/recurring/models.py +3 -3
  103. fin_infra/recurring/normalizer.py +3 -2
  104. fin_infra/recurring/normalizers.py +11 -10
  105. fin_infra/recurring/summary.py +13 -15
  106. fin_infra/scaffold/__init__.py +1 -1
  107. fin_infra/scaffold/budgets.py +9 -9
  108. fin_infra/scaffold/goals.py +5 -5
  109. fin_infra/security/__init__.py +8 -8
  110. fin_infra/security/encryption.py +6 -6
  111. fin_infra/security/models.py +7 -7
  112. fin_infra/security/pii_filter.py +6 -6
  113. fin_infra/security/pii_patterns.py +1 -1
  114. fin_infra/security/token_store.py +3 -1
  115. fin_infra/settings.py +2 -1
  116. fin_infra/tax/__init__.py +2 -2
  117. fin_infra/tax/add.py +3 -2
  118. fin_infra/tax/tlh.py +5 -5
  119. fin_infra/utils/http.py +5 -3
  120. fin_infra/utils/retry.py +2 -1
  121. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
  122. fin_infra-0.1.82.dist-info/RECORD +180 -0
  123. fin_infra-0.1.62.dist-info/RECORD +0 -180
  124. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  125. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  126. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
@@ -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. " "Install with: pip install ai-infra"
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}. " f"Use 'google', 'openai', or 'anthropic'"
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
- try:
420
- from fin_infra.conversation import FinancialPlanningConversation
421
-
422
- conversation = FinancialPlanningConversation(
423
- llm=llm,
424
- cache=cache, # Required for context storage
425
- provider=llm_provider,
426
- model_name=model_name,
427
- )
428
- except ImportError:
429
- # conversation module not yet implemented, skip
430
- pass
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(
@@ -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['total_net_worth']:,.0f}
483
- Previous net worth: ${previous['total_net_worth']:,.0f}
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['total_assets']:,.0f}
488
- Liabilities: ${current['total_liabilities']:,.0f}
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
 
@@ -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
- @computed_field
211
- @property
212
- def total_assets(self) -> float:
213
- """Sum of all asset categories."""
214
- return (
215
- self.cash
216
- + self.investments
217
- + self.crypto
218
- + self.real_estate
219
- + self.vehicles
220
- + self.other_assets
221
- )
222
-
223
- @computed_field
224
- @property
225
- def cash_percentage(self) -> float:
226
- """Cash as percentage of total assets."""
227
- return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
228
-
229
- @computed_field
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
- @computed_field
236
- @property
237
- def crypto_percentage(self) -> float:
238
- """Crypto as percentage of total assets."""
239
- return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
240
-
241
- @computed_field
242
- @property
243
- def real_estate_percentage(self) -> float:
244
- """Real estate as percentage of total assets."""
245
- return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
246
-
247
- @computed_field
248
- @property
249
- def vehicles_percentage(self) -> float:
250
- """Vehicles as percentage of total assets."""
251
- return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
252
-
253
- @computed_field
254
- @property
255
- def other_percentage(self) -> float:
256
- """Other assets as percentage of total assets."""
257
- return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
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
- @computed_field
292
- @property
293
- def total_liabilities(self) -> float:
294
- """Sum of all liability categories."""
295
- return (
296
- self.credit_cards
297
- + self.mortgages
298
- + self.auto_loans
299
- + self.student_loans
300
- + self.personal_loans
301
- + self.lines_of_credit
302
- )
303
-
304
- @computed_field
305
- @property
306
- def credit_cards_percentage(self) -> float:
307
- """Credit cards as percentage of total liabilities."""
308
- return (
309
- (self.credit_cards / self.total_liabilities * 100)
310
- if self.total_liabilities > 0
311
- else 0.0
312
- )
313
-
314
- @computed_field
315
- @property
316
- def mortgages_percentage(self) -> float:
317
- """Mortgages as percentage of total liabilities."""
318
- return (
319
- (self.mortgages / self.total_liabilities * 100) if self.total_liabilities > 0 else 0.0
320
- )
321
-
322
- @computed_field
323
- @property
324
- def auto_loans_percentage(self) -> float:
325
- """Auto loans as percentage of total liabilities."""
326
- return (
327
- (self.auto_loans / self.total_liabilities * 100) if self.total_liabilities > 0 else 0.0
328
- )
329
-
330
- @computed_field
331
- @property
332
- def student_loans_percentage(self) -> float:
333
- """Student loans as percentage of total liabilities."""
334
- return (
335
- (self.student_loans / self.total_liabilities * 100)
336
- if self.total_liabilities > 0
337
- else 0.0
338
- )
339
-
340
- @computed_field
341
- @property
342
- def personal_loans_percentage(self) -> float:
343
- """Personal loans as percentage of total liabilities."""
344
- return (
345
- (self.personal_loans / self.total_liabilities * 100)
346
- if self.total_liabilities > 0
347
- else 0.0
348
- )
349
-
350
- @computed_field
351
- @property
352
- def lines_of_credit_percentage(self) -> float:
353
- """Lines of credit as percentage of total liabilities."""
354
- return (
355
- (self.lines_of_credit / self.total_liabilities * 100)
356
- if self.total_liabilities > 0
357
- else 0.0
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", # type: ignore
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 Query, HTTPException
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(..., alias="from", description="Source currency code (e.g., USD)"),
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.convert(amount, from_currency, to_currency)
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.amount,
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.timestamp.isoformat(),
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 Optional
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: Optional[str] = None):
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: Optional[DateType] = None
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.
@@ -37,7 +37,7 @@ Usage:
37
37
 
38
38
  from __future__ import annotations
39
39
 
40
- from typing import Callable
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