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
@@ -22,10 +22,12 @@ from typing import TYPE_CHECKING, Literal
22
22
  if TYPE_CHECKING:
23
23
  from fastapi import FastAPI
24
24
 
25
- from ..providers.base import BrokerageProvider
26
- from pydantic import BaseModel, Field
27
25
  from decimal import Decimal
28
26
 
27
+ from pydantic import BaseModel, Field
28
+
29
+ from ..providers.base import BrokerageProvider
30
+
29
31
 
30
32
  # Request model for order submission (used by add_brokerage FastAPI routes)
31
33
  class OrderRequest(BaseModel):
@@ -123,11 +125,11 @@ def easy_brokerage(
123
125
  )
124
126
 
125
127
  else:
126
- raise ValueError(f"Unknown brokerage provider: {provider_name}. " f"Supported: alpaca")
128
+ raise ValueError(f"Unknown brokerage provider: {provider_name}. Supported: alpaca")
127
129
 
128
130
 
129
131
  def add_brokerage(
130
- app: "FastAPI",
132
+ app: FastAPI,
131
133
  *,
132
134
  provider: str | BrokerageProvider | None = None,
133
135
  mode: Literal["paper", "live"] = "paper",
@@ -206,13 +208,15 @@ def add_brokerage(
206
208
  >>> broker = add_brokerage(app, mode="live")
207
209
  >>> # Only use in production with proper safeguards and risk management
208
210
  """
209
- from svc_infra.api.fastapi.dual.public import public_router
210
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
211
211
  from fastapi import HTTPException, Query
212
+ from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
213
+ from svc_infra.api.fastapi.dual.public import public_router
212
214
 
213
215
  # Initialize provider if string or None
214
216
  if isinstance(provider, str):
215
- brokerage_provider = easy_brokerage(provider=provider, mode=mode, **config)
217
+ # Cast provider string to Literal type for type checker
218
+ provider_literal: Literal["alpaca"] | None = provider if provider == "alpaca" else None # type: ignore[assignment]
219
+ brokerage_provider = easy_brokerage(provider=provider_literal, mode=mode, **config)
216
220
  elif provider is None:
217
221
  brokerage_provider = easy_brokerage(mode=mode, **config)
218
222
  else:
@@ -232,7 +236,7 @@ def add_brokerage(
232
236
  account = brokerage_provider.get_account()
233
237
  return account
234
238
  except Exception as e:
235
- raise HTTPException(status_code=500, detail=f"Error fetching account: {str(e)}")
239
+ raise HTTPException(status_code=500, detail=f"Error fetching account: {e!s}")
236
240
 
237
241
  @router.get("/positions")
238
242
  async def list_positions():
@@ -241,10 +245,10 @@ def add_brokerage(
241
245
  Returns list of positions with symbol, quantity, P/L, etc.
242
246
  """
243
247
  try:
244
- positions = brokerage_provider.positions()
248
+ positions = list(brokerage_provider.positions()) # Convert Iterable to list for len()
245
249
  return {"positions": positions, "count": len(positions)}
246
250
  except Exception as e:
247
- raise HTTPException(status_code=500, detail=f"Error fetching positions: {str(e)}")
251
+ raise HTTPException(status_code=500, detail=f"Error fetching positions: {e!s}")
248
252
 
249
253
  @router.get("/positions/{symbol}")
250
254
  async def get_position(symbol: str):
@@ -257,9 +261,7 @@ def add_brokerage(
257
261
  position = brokerage_provider.get_position(symbol)
258
262
  return position
259
263
  except Exception as e:
260
- raise HTTPException(
261
- status_code=404, detail=f"Position not found for {symbol}: {str(e)}"
262
- )
264
+ raise HTTPException(status_code=404, detail=f"Position not found for {symbol}: {e!s}")
263
265
 
264
266
  @router.delete("/positions/{symbol}")
265
267
  async def close_position(symbol: str):
@@ -272,7 +274,7 @@ def add_brokerage(
272
274
  order = brokerage_provider.close_position(symbol)
273
275
  return {"message": f"Closing position for {symbol}", "order": order}
274
276
  except Exception as e:
275
- raise HTTPException(status_code=400, detail=f"Error closing position: {str(e)}")
277
+ raise HTTPException(status_code=400, detail=f"Error closing position: {e!s}")
276
278
 
277
279
  @router.post("/orders")
278
280
  async def submit_order(order_request: OrderRequest):
@@ -293,7 +295,7 @@ def add_brokerage(
293
295
  )
294
296
  return order
295
297
  except Exception as e:
296
- raise HTTPException(status_code=400, detail=f"Error submitting order: {str(e)}")
298
+ raise HTTPException(status_code=400, detail=f"Error submitting order: {e!s}")
297
299
 
298
300
  @router.get("/orders")
299
301
  async def list_orders(
@@ -310,7 +312,7 @@ def add_brokerage(
310
312
  orders = brokerage_provider.list_orders(status=status, limit=limit)
311
313
  return {"orders": orders, "count": len(orders)}
312
314
  except Exception as e:
313
- raise HTTPException(status_code=500, detail=f"Error fetching orders: {str(e)}")
315
+ raise HTTPException(status_code=500, detail=f"Error fetching orders: {e!s}")
314
316
 
315
317
  @router.get("/orders/{order_id}")
316
318
  async def get_order(order_id: str):
@@ -323,7 +325,7 @@ def add_brokerage(
323
325
  order = brokerage_provider.get_order(order_id)
324
326
  return order
325
327
  except Exception as e:
326
- raise HTTPException(status_code=404, detail=f"Order not found: {str(e)}")
328
+ raise HTTPException(status_code=404, detail=f"Order not found: {e!s}")
327
329
 
328
330
  @router.delete("/orders/{order_id}")
329
331
  async def cancel_order(order_id: str):
@@ -336,7 +338,7 @@ def add_brokerage(
336
338
  brokerage_provider.cancel_order(order_id)
337
339
  return {"message": f"Order {order_id} canceled successfully"}
338
340
  except Exception as e:
339
- raise HTTPException(status_code=400, detail=f"Error canceling order: {str(e)}")
341
+ raise HTTPException(status_code=400, detail=f"Error canceling order: {e!s}")
340
342
 
341
343
  @router.get("/portfolio/history")
342
344
  async def get_portfolio_history(
@@ -353,9 +355,7 @@ def add_brokerage(
353
355
  history = brokerage_provider.get_portfolio_history(period=period, timeframe=timeframe)
354
356
  return history
355
357
  except Exception as e:
356
- raise HTTPException(
357
- status_code=500, detail=f"Error fetching portfolio history: {str(e)}"
358
- )
358
+ raise HTTPException(status_code=500, detail=f"Error fetching portfolio history: {e!s}")
359
359
 
360
360
  # Watchlist routes
361
361
  @router.post("/watchlists")
@@ -373,7 +373,7 @@ def add_brokerage(
373
373
  watchlist = brokerage_provider.create_watchlist(name=name, symbols=symbols)
374
374
  return watchlist
375
375
  except Exception as e:
376
- raise HTTPException(status_code=400, detail=f"Error creating watchlist: {str(e)}")
376
+ raise HTTPException(status_code=400, detail=f"Error creating watchlist: {e!s}")
377
377
 
378
378
  @router.get("/watchlists")
379
379
  async def list_watchlists():
@@ -382,7 +382,7 @@ def add_brokerage(
382
382
  watchlists = brokerage_provider.list_watchlists()
383
383
  return {"watchlists": watchlists, "count": len(watchlists)}
384
384
  except Exception as e:
385
- raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {str(e)}")
385
+ raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {e!s}")
386
386
 
387
387
  @router.get("/watchlists/{watchlist_id}")
388
388
  async def get_watchlist(watchlist_id: str):
@@ -395,7 +395,7 @@ def add_brokerage(
395
395
  watchlist = brokerage_provider.get_watchlist(watchlist_id)
396
396
  return watchlist
397
397
  except Exception as e:
398
- raise HTTPException(status_code=404, detail=f"Watchlist not found: {str(e)}")
398
+ raise HTTPException(status_code=404, detail=f"Watchlist not found: {e!s}")
399
399
 
400
400
  @router.delete("/watchlists/{watchlist_id}")
401
401
  async def delete_watchlist(watchlist_id: str):
@@ -408,7 +408,7 @@ def add_brokerage(
408
408
  brokerage_provider.delete_watchlist(watchlist_id)
409
409
  return {"message": f"Watchlist {watchlist_id} deleted successfully"}
410
410
  except Exception as e:
411
- raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {str(e)}")
411
+ raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {e!s}")
412
412
 
413
413
  @router.post("/watchlists/{watchlist_id}/symbols")
414
414
  async def add_to_watchlist(
@@ -424,7 +424,7 @@ def add_brokerage(
424
424
  watchlist = brokerage_provider.add_to_watchlist(watchlist_id, symbol)
425
425
  return watchlist
426
426
  except Exception as e:
427
- raise HTTPException(status_code=400, detail=f"Error adding symbol: {str(e)}")
427
+ raise HTTPException(status_code=400, detail=f"Error adding symbol: {e!s}")
428
428
 
429
429
  @router.delete("/watchlists/{watchlist_id}/symbols/{symbol}")
430
430
  async def remove_from_watchlist(watchlist_id: str, symbol: str):
@@ -438,7 +438,7 @@ def add_brokerage(
438
438
  watchlist = brokerage_provider.remove_from_watchlist(watchlist_id, symbol)
439
439
  return watchlist
440
440
  except Exception as e:
441
- raise HTTPException(status_code=400, detail=f"Error removing symbol: {str(e)}")
441
+ raise HTTPException(status_code=400, detail=f"Error removing symbol: {e!s}")
442
442
 
443
443
  # Mount router
444
444
  app.include_router(router, include_in_schema=True)
@@ -105,12 +105,12 @@ def __getattr__(name: str):
105
105
  ):
106
106
  from fin_infra.budgets.models import ( # noqa: F401
107
107
  Budget,
108
- BudgetType,
109
- BudgetPeriod,
108
+ BudgetAlert,
110
109
  BudgetCategory,
110
+ BudgetPeriod,
111
111
  BudgetProgress,
112
- BudgetAlert,
113
112
  BudgetTemplate,
113
+ BudgetType,
114
114
  )
115
115
 
116
116
  return locals()[name]
fin_infra/budgets/add.py CHANGED
@@ -22,7 +22,6 @@ Generic Design:
22
22
  from __future__ import annotations
23
23
 
24
24
  from datetime import datetime
25
- from typing import Optional
26
25
 
27
26
  from fastapi import FastAPI, HTTPException, Query
28
27
  from pydantic import BaseModel, Field
@@ -42,16 +41,16 @@ class CreateBudgetRequest(BaseModel):
42
41
  type: BudgetType = Field(..., description="Budget type")
43
42
  period: BudgetPeriod = Field(..., description="Budget period")
44
43
  categories: dict[str, float] = Field(..., description="Category allocations")
45
- start_date: Optional[datetime] = Field(None, description="Start date (defaults to now)")
44
+ start_date: datetime | None = Field(None, description="Start date (defaults to now)")
46
45
  rollover_enabled: bool = Field(False, description="Enable rollover")
47
46
 
48
47
 
49
48
  class UpdateBudgetRequest(BaseModel):
50
49
  """Request model for updating a budget."""
51
50
 
52
- name: Optional[str] = Field(None, description="Updated budget name")
53
- categories: Optional[dict[str, float]] = Field(None, description="Updated categories")
54
- rollover_enabled: Optional[bool] = Field(None, description="Updated rollover setting")
51
+ name: str | None = Field(None, description="Updated budget name")
52
+ categories: dict[str, float] | None = Field(None, description="Updated categories")
53
+ rollover_enabled: bool | None = Field(None, description="Updated rollover setting")
55
54
 
56
55
 
57
56
  class ApplyTemplateRequest(BaseModel):
@@ -60,14 +59,14 @@ class ApplyTemplateRequest(BaseModel):
60
59
  user_id: str = Field(..., description="User identifier")
61
60
  template_name: str = Field(..., description="Template name (e.g., '50_30_20')")
62
61
  total_income: float = Field(..., description="Total income/budget amount", gt=0)
63
- budget_name: Optional[str] = Field(None, description="Optional budget name")
64
- start_date: Optional[datetime] = Field(None, description="Optional start date")
62
+ budget_name: str | None = Field(None, description="Optional budget name")
63
+ start_date: datetime | None = Field(None, description="Optional start date")
65
64
 
66
65
 
67
66
  def add_budgets(
68
67
  app: FastAPI,
69
- tracker: Optional[BudgetTracker] = None,
70
- db_url: Optional[str] = None,
68
+ tracker: BudgetTracker | None = None,
69
+ db_url: str | None = None,
71
70
  prefix: str = "/budgets",
72
71
  ) -> BudgetTracker:
73
72
  """Add budget management endpoints to FastAPI app.
@@ -162,13 +161,13 @@ def add_budgets(
162
161
  except ValueError as e:
163
162
  raise HTTPException(status_code=400, detail=str(e))
164
163
  except Exception as e:
165
- raise HTTPException(status_code=500, detail=f"Failed to create budget: {str(e)}")
164
+ raise HTTPException(status_code=500, detail=f"Failed to create budget: {e!s}")
166
165
 
167
166
  # Endpoint 2: List budgets
168
167
  @router.get("", response_model=list[Budget], summary="List Budgets")
169
168
  async def list_budgets(
170
169
  user_id: str = Query(..., description="User identifier"),
171
- type: Optional[BudgetType] = Query(None, description="Filter by budget type"),
170
+ type: BudgetType | None = Query(None, description="Filter by budget type"),
172
171
  ) -> list[Budget]:
173
172
  """
174
173
  List budgets for a user.
@@ -189,7 +188,7 @@ def add_budgets(
189
188
  budgets = await tracker.get_budgets(user_id=user_id, type=type)
190
189
  return budgets
191
190
  except Exception as e:
192
- raise HTTPException(status_code=500, detail=f"Failed to list budgets: {str(e)}")
191
+ raise HTTPException(status_code=500, detail=f"Failed to list budgets: {e!s}")
193
192
 
194
193
  # Endpoint 3: Get single budget
195
194
  @router.get("/{budget_id}", response_model=Budget, summary="Get Budget")
@@ -217,7 +216,7 @@ def add_budgets(
217
216
  except ValueError as e:
218
217
  raise HTTPException(status_code=404, detail=str(e))
219
218
  except Exception as e:
220
- raise HTTPException(status_code=500, detail=f"Failed to get budget: {str(e)}")
219
+ raise HTTPException(status_code=500, detail=f"Failed to get budget: {e!s}")
221
220
 
222
221
  # Endpoint 4: Update budget
223
222
  @router.patch("/{budget_id}", response_model=Budget, summary="Update Budget")
@@ -269,7 +268,7 @@ def add_budgets(
269
268
  else:
270
269
  raise HTTPException(status_code=400, detail=error_msg)
271
270
  except Exception as e:
272
- raise HTTPException(status_code=500, detail=f"Failed to update budget: {str(e)}")
271
+ raise HTTPException(status_code=500, detail=f"Failed to update budget: {e!s}")
273
272
 
274
273
  # Endpoint 5: Delete budget
275
274
  @router.delete("/{budget_id}", status_code=204, summary="Delete Budget", response_model=None)
@@ -296,7 +295,7 @@ def add_budgets(
296
295
  except ValueError as e:
297
296
  raise HTTPException(status_code=404, detail=str(e))
298
297
  except Exception as e:
299
- raise HTTPException(status_code=500, detail=f"Failed to delete budget: {str(e)}")
298
+ raise HTTPException(status_code=500, detail=f"Failed to delete budget: {e!s}")
300
299
 
301
300
  # Endpoint 6: Get budget progress
302
301
  @router.get(
@@ -328,7 +327,7 @@ def add_budgets(
328
327
  except ValueError as e:
329
328
  raise HTTPException(status_code=404, detail=str(e))
330
329
  except Exception as e:
331
- raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {str(e)}")
330
+ raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {e!s}")
332
331
 
333
332
  # Endpoint 7: List templates
334
333
  @router.get("/templates/list", response_model=dict, summary="List Budget Templates")
@@ -402,7 +401,7 @@ def add_budgets(
402
401
  raise HTTPException(status_code=400, detail=str(e))
403
402
  except Exception as e:
404
403
  raise HTTPException(
405
- status_code=500, detail=f"Failed to create budget from template: {str(e)}"
404
+ status_code=500, detail=f"Failed to create budget from template: {e!s}"
406
405
  )
407
406
 
408
407
  # Mount router
@@ -35,7 +35,7 @@ Example:
35
35
  from __future__ import annotations
36
36
 
37
37
  from datetime import datetime
38
- from typing import TYPE_CHECKING, List, Optional
38
+ from typing import TYPE_CHECKING, Optional
39
39
 
40
40
  from fin_infra.budgets.models import (
41
41
  AlertSeverity,
@@ -52,7 +52,7 @@ async def check_budget_alerts(
52
52
  budget_id: str,
53
53
  tracker: BudgetTracker,
54
54
  thresholds: Optional[dict[str, float]] = None,
55
- ) -> List[BudgetAlert]:
55
+ ) -> list[BudgetAlert]:
56
56
  """
57
57
  Check budget for alerts (overspending, approaching limits, unusual patterns).
58
58
 
@@ -111,7 +111,7 @@ async def check_budget_alerts(
111
111
  # Get budget progress
112
112
  progress = await tracker.get_budget_progress(budget_id)
113
113
 
114
- alerts: List[BudgetAlert] = []
114
+ alerts: list[BudgetAlert] = []
115
115
 
116
116
  # Check each category for alerts
117
117
  for category in progress.categories:
@@ -36,7 +36,7 @@ from __future__ import annotations
36
36
 
37
37
  import uuid
38
38
  from datetime import datetime, timedelta
39
- from typing import TYPE_CHECKING, List, Optional
39
+ from typing import TYPE_CHECKING, Optional
40
40
 
41
41
  from sqlalchemy.ext.asyncio import async_sessionmaker
42
42
 
@@ -155,7 +155,7 @@ class BudgetTracker:
155
155
  BudgetType(type)
156
156
  except ValueError:
157
157
  raise ValueError(
158
- f"Invalid budget type: {type}. " f"Valid types: {[t.value for t in BudgetType]}"
158
+ f"Invalid budget type: {type}. Valid types: {[t.value for t in BudgetType]}"
159
159
  )
160
160
 
161
161
  # Validate budget period
@@ -163,8 +163,7 @@ class BudgetTracker:
163
163
  BudgetPeriod(period)
164
164
  except ValueError:
165
165
  raise ValueError(
166
- f"Invalid budget period: {period}. "
167
- f"Valid periods: {[p.value for p in BudgetPeriod]}"
166
+ f"Invalid budget period: {period}. Valid periods: {[p.value for p in BudgetPeriod]}"
168
167
  )
169
168
 
170
169
  # Validate categories
@@ -207,7 +206,7 @@ class BudgetTracker:
207
206
  self,
208
207
  user_id: str,
209
208
  type: Optional[str] = None,
210
- ) -> List[Budget]:
209
+ ) -> list[Budget]:
211
210
  """
212
211
  Get all budgets for a user.
213
212
 
@@ -22,11 +22,14 @@ Example usage:
22
22
  rate = irr(cashflows)
23
23
  """
24
24
 
25
- from typing import Iterable
25
+ from typing import TYPE_CHECKING
26
26
 
27
27
  import numpy_financial as npf
28
28
 
29
- from .core import npv, irr
29
+ if TYPE_CHECKING:
30
+ from fastapi import FastAPI
31
+
32
+ from .core import irr, npv
30
33
 
31
34
  __all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
32
35
 
@@ -110,7 +113,7 @@ def pv(rate: float, nper: int, pmt: float, fv: float = 0, when: str = "end") ->
110
113
 
111
114
 
112
115
  def add_cashflows(
113
- app: "FastAPI", # type: ignore
116
+ app: "FastAPI",
114
117
  *,
115
118
  prefix: str = "/cashflows",
116
119
  ) -> None:
@@ -169,16 +172,11 @@ def add_cashflows(
169
172
  - Integrated with svc-infra observability
170
173
  - Scoped docs at {prefix}/docs
171
174
  """
172
- from typing import TYPE_CHECKING
173
-
174
- if TYPE_CHECKING:
175
- from fastapi import FastAPI
176
-
177
175
  from pydantic import BaseModel, Field
176
+ from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
178
177
 
179
178
  # Import svc-infra public router (no auth - utility calculations)
180
179
  from svc_infra.api.fastapi.dual.public import public_router
181
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
182
180
 
183
181
  # Request/Response models
184
182
  class NPVRequest(BaseModel):
@@ -254,4 +252,4 @@ def add_cashflows(
254
252
  # Mount router
255
253
  app.include_router(router, include_in_schema=True)
256
254
 
257
- print(f"Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
255
+ print("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Iterable
3
+ from collections.abc import Iterable
4
4
 
5
5
  import numpy as np
6
6
  import numpy_financial as npf
@@ -44,7 +44,7 @@ from .taxonomy import Category, CategoryGroup, get_all_categories, get_category_
44
44
  try:
45
45
  from .llm_layer import LLMCategorizer
46
46
  except ImportError:
47
- LLMCategorizer = None
47
+ LLMCategorizer = None # type: ignore[assignment,misc]
48
48
 
49
49
  __all__ = [
50
50
  # Easy setup
@@ -6,10 +6,10 @@ Uses svc-infra dual routers for consistent behavior.
6
6
  """
7
7
 
8
8
  import time
9
- from typing import Optional
10
9
 
11
10
  from fastapi import FastAPI, HTTPException
12
11
 
12
+ from . import rules
13
13
  from .ease import easy_categorization
14
14
  from .engine import CategorizationEngine
15
15
  from .models import (
@@ -18,7 +18,6 @@ from .models import (
18
18
  CategoryStats,
19
19
  )
20
20
  from .taxonomy import CategoryGroup, count_categories, get_all_categories
21
- from . import rules
22
21
 
23
22
 
24
23
  def add_categorization(
@@ -96,7 +95,8 @@ def add_categorization(
96
95
  start_time = time.perf_counter()
97
96
 
98
97
  try:
99
- prediction = engine.categorize(
98
+ # Await the async categorize method
99
+ prediction = await engine.categorize(
100
100
  merchant_name=request.merchant_name,
101
101
  user_id=request.user_id,
102
102
  include_alternatives=request.include_alternatives,
@@ -121,7 +121,7 @@ def add_categorization(
121
121
  raise HTTPException(status_code=400, detail=str(e))
122
122
 
123
123
  @router.get("/categories")
124
- async def list_categories(group: Optional[CategoryGroup] = None):
124
+ async def list_categories(group: CategoryGroup | None = None):
125
125
  """
126
126
  List all available categories.
127
127
 
@@ -135,21 +135,19 @@ def add_categorization(
135
135
  categories = get_all_categories()
136
136
 
137
137
  # Return category metadata
138
- return [
139
- {
140
- "name": cat.value,
141
- "group": get_category_metadata(cat).group.value
142
- if get_category_metadata(cat)
143
- else None,
144
- "display_name": get_category_metadata(cat).display_name
145
- if get_category_metadata(cat)
146
- else cat.value,
147
- "description": get_category_metadata(cat).description
148
- if get_category_metadata(cat)
149
- else None,
150
- }
151
- for cat in categories
152
- ]
138
+ result = []
139
+ for cat in categories:
140
+ meta = get_category_metadata(cat)
141
+ result.append(
142
+ {
143
+ "name": cat.value,
144
+ "group": meta.group.value if meta else None,
145
+ "display_name": meta.display_name if meta else cat.value,
146
+ "description": meta.description if meta else None,
147
+ }
148
+ )
149
+
150
+ return result
153
151
 
154
152
  @router.get("/stats", response_model=CategoryStats)
155
153
  async def get_stats():
@@ -13,7 +13,7 @@ from .engine import CategorizationEngine
13
13
  try:
14
14
  from .llm_layer import LLMCategorizer
15
15
  except ImportError:
16
- LLMCategorizer = None
16
+ LLMCategorizer = None # type: ignore[assignment,misc]
17
17
 
18
18
 
19
19
  def easy_categorization(
@@ -113,7 +113,7 @@ def easy_categorization(
113
113
  if enable_llm:
114
114
  if LLMCategorizer is None:
115
115
  raise ImportError(
116
- "LLM support requires ai-infra package. " "Install with: pip install ai-infra"
116
+ "LLM support requires ai-infra package. Install with: pip install ai-infra"
117
117
  )
118
118
 
119
119
  # Map provider names to ai-infra provider format
@@ -125,8 +125,7 @@ def easy_categorization(
125
125
  ai_infra_provider = provider_map.get(llm_provider)
126
126
  if not ai_infra_provider:
127
127
  raise ValueError(
128
- f"Unsupported LLM provider: {llm_provider}. "
129
- f"Use 'google', 'openai', or 'anthropic'."
128
+ f"Unsupported LLM provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'."
130
129
  )
131
130
 
132
131
  # Default models per provider
@@ -10,8 +10,8 @@ Hybrid categorization engine (exact → regex → ML → LLM).
10
10
  Expected overall accuracy: 95-97% (V2 with LLM)
11
11
  """
12
12
 
13
- import time
14
13
  import logging
14
+ import time
15
15
  from pathlib import Path
16
16
  from typing import Optional
17
17
 
@@ -23,7 +23,7 @@ from .taxonomy import Category
23
23
  try:
24
24
  from .llm_layer import LLMCategorizer
25
25
  except ImportError:
26
- LLMCategorizer = None
26
+ LLMCategorizer = None # type: ignore[assignment,misc]
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
@@ -51,7 +51,7 @@ class CategorizationEngine:
51
51
  enable_ml: bool = False,
52
52
  enable_llm: bool = False,
53
53
  confidence_threshold: float = 0.6,
54
- model_path: Optional[Path] = None,
54
+ model_path: Path | None = None,
55
55
  llm_categorizer: Optional["LLMCategorizer"] = None,
56
56
  ):
57
57
  self.enable_ml = enable_ml
@@ -81,7 +81,7 @@ class CategorizationEngine:
81
81
  async def categorize(
82
82
  self,
83
83
  merchant_name: str,
84
- user_id: Optional[str] = None,
84
+ user_id: str | None = None,
85
85
  include_alternatives: bool = False,
86
86
  ) -> CategoryPrediction:
87
87
  """
@@ -95,7 +95,7 @@ class CategorizationEngine:
95
95
  Returns:
96
96
  CategoryPrediction with category, confidence, and method
97
97
  """
98
- start_time = time.perf_counter()
98
+ time.perf_counter()
99
99
 
100
100
  # Normalize merchant name
101
101
  normalized = self._normalize(merchant_name)
@@ -195,7 +195,7 @@ class CategorizationEngine:
195
195
 
196
196
  def _predict_ml(
197
197
  self, merchant_name: str, include_alternatives: bool = False
198
- ) -> Optional[CategoryPrediction]:
198
+ ) -> CategoryPrediction | None:
199
199
  """
200
200
  Predict category using ML model.
201
201
 
@@ -251,8 +251,7 @@ class CategorizationEngine:
251
251
  )
252
252
 
253
253
  except Exception as e:
254
- # Log error and return None (will fallback to uncategorized)
255
- print(f"ML prediction error: {e}")
254
+ logger.error("ML prediction error: %s", e)
256
255
  return None
257
256
 
258
257
  def _load_ml_model(self) -> None:
@@ -273,8 +272,10 @@ class CategorizationEngine:
273
272
  vectorizer_file = self.model_path / "vectorizer.joblib"
274
273
 
275
274
  if not model_file.exists() or not vectorizer_file.exists():
276
- print(f"ML model not found at {self.model_path}")
277
- print("Run training script to generate model files")
275
+ logger.warning(
276
+ "ML model not found at %s. Run training script to generate model files.",
277
+ self.model_path,
278
+ )
278
279
  return
279
280
 
280
281
  try:
@@ -282,12 +283,14 @@ class CategorizationEngine:
282
283
 
283
284
  self._ml_model = joblib.load(model_file)
284
285
  self._ml_vectorizer = joblib.load(vectorizer_file)
285
- print(f"Loaded ML model from {self.model_path}")
286
+ logger.info("Loaded ML model from %s", self.model_path)
286
287
  except ImportError:
287
- print("scikit-learn not installed. ML predictions disabled.")
288
- print("Install with: pip install scikit-learn")
288
+ logger.warning(
289
+ "scikit-learn not installed. ML predictions disabled. "
290
+ "Install with: pip install scikit-learn"
291
+ )
289
292
  except Exception as e:
290
- print(f"Error loading ML model: {e}")
293
+ logger.error("Error loading ML model: %s", e)
291
294
 
292
295
  def add_rule(
293
296
  self,
@@ -322,7 +325,7 @@ class CategorizationEngine:
322
325
 
323
326
 
324
327
  # Singleton instance (for easy access)
325
- _default_engine: Optional[CategorizationEngine] = None
328
+ _default_engine: CategorizationEngine | None = None
326
329
 
327
330
 
328
331
  def get_engine() -> CategorizationEngine:
@@ -333,9 +336,9 @@ def get_engine() -> CategorizationEngine:
333
336
  return _default_engine
334
337
 
335
338
 
336
- def categorize(
339
+ async def categorize(
337
340
  merchant_name: str,
338
- user_id: Optional[str] = None,
341
+ user_id: str | None = None,
339
342
  include_alternatives: bool = False,
340
343
  ) -> CategoryPrediction:
341
344
  """
@@ -350,4 +353,4 @@ def categorize(
350
353
  CategoryPrediction with category, confidence, and method
351
354
  """
352
355
  engine = get_engine()
353
- return engine.categorize(merchant_name, user_id, include_alternatives)
356
+ return await engine.categorize(merchant_name, user_id, include_alternatives)