fin-infra 0.1.81__py3-none-any.whl → 0.1.83__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 (96) hide show
  1. fin_infra/analytics/__init__.py +3 -2
  2. fin_infra/analytics/add.py +21 -21
  3. fin_infra/analytics/ease.py +19 -20
  4. fin_infra/analytics/portfolio.py +6 -6
  5. fin_infra/analytics/projections.py +1 -3
  6. fin_infra/banking/__init__.py +27 -27
  7. fin_infra/banking/history.py +4 -5
  8. fin_infra/banking/utils.py +19 -18
  9. fin_infra/brokerage/__init__.py +22 -24
  10. fin_infra/budgets/__init__.py +3 -3
  11. fin_infra/budgets/add.py +16 -17
  12. fin_infra/cashflows/__init__.py +2 -2
  13. fin_infra/categorization/add.py +2 -3
  14. fin_infra/categorization/engine.py +6 -6
  15. fin_infra/categorization/llm_layer.py +6 -5
  16. fin_infra/categorization/rules.py +2 -4
  17. fin_infra/categorization/taxonomy.py +2 -2
  18. fin_infra/chat/__init__.py +5 -5
  19. fin_infra/chat/planning.py +0 -1
  20. fin_infra/cli/cmds/scaffold_cmds.py +10 -11
  21. fin_infra/clients/plaid.py +1 -1
  22. fin_infra/compliance/__init__.py +5 -5
  23. fin_infra/credit/add.py +6 -7
  24. fin_infra/credit/experian/auth.py +2 -2
  25. fin_infra/credit/experian/client.py +1 -1
  26. fin_infra/credit/experian/provider.py +4 -4
  27. fin_infra/crypto/__init__.py +7 -9
  28. fin_infra/documents/add.py +6 -8
  29. fin_infra/documents/analysis.py +8 -8
  30. fin_infra/documents/ease.py +14 -14
  31. fin_infra/documents/ocr.py +7 -7
  32. fin_infra/documents/storage.py +21 -13
  33. fin_infra/exceptions.py +0 -1
  34. fin_infra/goals/__init__.py +8 -8
  35. fin_infra/goals/add.py +30 -30
  36. fin_infra/goals/funding.py +1 -1
  37. fin_infra/goals/management.py +2 -3
  38. fin_infra/goals/milestones.py +1 -2
  39. fin_infra/goals/models.py +7 -11
  40. fin_infra/insights/__init__.py +2 -2
  41. fin_infra/insights/aggregator.py +1 -1
  42. fin_infra/investments/__init__.py +1 -1
  43. fin_infra/investments/add.py +23 -23
  44. fin_infra/investments/providers/base.py +2 -3
  45. fin_infra/investments/providers/plaid.py +9 -9
  46. fin_infra/investments/providers/snaptrade.py +10 -10
  47. fin_infra/markets/__init__.py +1 -1
  48. fin_infra/models/__init__.py +10 -10
  49. fin_infra/models/brokerage.py +2 -1
  50. fin_infra/models/candle.py +1 -0
  51. fin_infra/models/money.py +1 -0
  52. fin_infra/models/quotes.py +4 -3
  53. fin_infra/models/tax.py +2 -1
  54. fin_infra/models/transactions.py +3 -4
  55. fin_infra/net_worth/insights.py +0 -1
  56. fin_infra/normalization/__init__.py +2 -2
  57. fin_infra/normalization/providers/exchangerate.py +5 -5
  58. fin_infra/providers/banking/plaid_client.py +5 -5
  59. fin_infra/providers/banking/teller_client.py +7 -6
  60. fin_infra/providers/base.py +1 -1
  61. fin_infra/providers/brokerage/alpaca.py +3 -3
  62. fin_infra/providers/market/alphavantage.py +5 -10
  63. fin_infra/providers/market/ccxt_crypto.py +2 -2
  64. fin_infra/providers/market/coingecko.py +5 -6
  65. fin_infra/providers/market/yahoo.py +5 -5
  66. fin_infra/providers/tax/__init__.py +1 -1
  67. fin_infra/providers/tax/irs.py +1 -1
  68. fin_infra/providers/tax/mock.py +5 -5
  69. fin_infra/providers/tax/taxbit.py +1 -1
  70. fin_infra/recurring/__init__.py +6 -6
  71. fin_infra/recurring/add.py +5 -4
  72. fin_infra/recurring/detector.py +7 -7
  73. fin_infra/recurring/detectors_llm.py +6 -6
  74. fin_infra/recurring/ease.py +2 -4
  75. fin_infra/recurring/insights.py +13 -13
  76. fin_infra/recurring/normalizer.py +1 -1
  77. fin_infra/recurring/normalizers.py +4 -4
  78. fin_infra/recurring/summary.py +4 -6
  79. fin_infra/scaffold/budgets.py +6 -6
  80. fin_infra/scaffold/goals.py +1 -1
  81. fin_infra/security/__init__.py +8 -8
  82. fin_infra/security/encryption.py +6 -6
  83. fin_infra/security/models.py +7 -7
  84. fin_infra/security/pii_filter.py +6 -6
  85. fin_infra/settings.py +2 -1
  86. fin_infra/tax/__init__.py +1 -1
  87. fin_infra/tax/add.py +3 -2
  88. fin_infra/tax/tlh.py +5 -5
  89. fin_infra/utils/http.py +4 -3
  90. fin_infra/utils/retry.py +1 -1
  91. {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/METADATA +1 -1
  92. fin_infra-0.1.83.dist-info/RECORD +180 -0
  93. fin_infra-0.1.81.dist-info/RECORD +0 -180
  94. {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/LICENSE +0 -0
  95. {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/WHEEL +0 -0
  96. {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/entry_points.txt +0 -0
@@ -23,9 +23,10 @@ Example:
23
23
  from __future__ import annotations
24
24
 
25
25
  import ssl
26
- import httpx
27
26
  from typing import Any, cast
28
27
 
28
+ import httpx
29
+
29
30
  from ..base import BankingProvider
30
31
 
31
32
 
@@ -145,7 +146,7 @@ class TellerClient(BankingProvider):
145
146
  "products": ["accounts", "transactions", "balances", "identity"],
146
147
  },
147
148
  )
148
- return cast(str, response.get("enrollment_id", ""))
149
+ return cast("str", response.get("enrollment_id", ""))
149
150
 
150
151
  def exchange_public_token(self, public_token: str) -> dict:
151
152
  """Exchange public token for access token.
@@ -192,7 +193,7 @@ class TellerClient(BankingProvider):
192
193
  auth=(access_token, ""),
193
194
  )
194
195
  response.raise_for_status()
195
- return cast(list[dict[Any, Any]], response.json())
196
+ return cast("list[dict[Any, Any]]", response.json())
196
197
 
197
198
  def transactions(
198
199
  self,
@@ -235,7 +236,7 @@ class TellerClient(BankingProvider):
235
236
  params=params,
236
237
  )
237
238
  response.raise_for_status()
238
- return cast(list[dict[Any, Any]], response.json())
239
+ return cast("list[dict[Any, Any]]", response.json())
239
240
 
240
241
  def balances(self, access_token: str, account_id: str | None = None) -> dict:
241
242
  """Fetch current balances.
@@ -267,7 +268,7 @@ class TellerClient(BankingProvider):
267
268
  )
268
269
 
269
270
  response.raise_for_status()
270
- return cast(dict[Any, Any], response.json())
271
+ return cast("dict[Any, Any]", response.json())
271
272
 
272
273
  def identity(self, access_token: str) -> dict:
273
274
  """Fetch identity/account holder information.
@@ -291,7 +292,7 @@ class TellerClient(BankingProvider):
291
292
  auth=(access_token, ""),
292
293
  )
293
294
  response.raise_for_status()
294
- return cast(dict[Any, Any], response.json())
295
+ return cast("dict[Any, Any]", response.json())
295
296
 
296
297
  def __del__(self) -> None:
297
298
  """Close HTTP client on cleanup."""
@@ -25,8 +25,8 @@ Provider Categories:
25
25
  from __future__ import annotations
26
26
 
27
27
  from abc import ABC, abstractmethod
28
- from typing import Any
29
28
  from collections.abc import Iterable, Sequence
29
+ from typing import Any
30
30
 
31
31
  from ..models import Candle, Quote
32
32
 
@@ -314,8 +314,8 @@ class AlpacaBrokerage(BrokerageProvider):
314
314
  Alpaca entities have a _raw attribute with the API response data.
315
315
  """
316
316
  if hasattr(obj, "_raw"):
317
- return cast(dict[Any, Any], obj._raw)
317
+ return cast("dict[Any, Any]", obj._raw)
318
318
  elif hasattr(obj, "__dict__"):
319
- return cast(dict[Any, Any], obj.__dict__)
319
+ return cast("dict[Any, Any]", obj.__dict__)
320
320
  else:
321
- return cast(dict[Any, Any], obj)
321
+ return cast("dict[Any, Any]", obj)
@@ -9,15 +9,14 @@ from __future__ import annotations
9
9
  import os
10
10
  import time
11
11
  from collections.abc import Sequence
12
+ from datetime import UTC, datetime
12
13
  from decimal import Decimal
13
- from datetime import datetime, timezone
14
14
 
15
15
  import httpx
16
16
 
17
- from .base import MarketDataProvider
18
- from ...models import Quote, Candle
17
+ from ...models import Candle, Quote
19
18
  from ...settings import Settings
20
-
19
+ from .base import MarketDataProvider
21
20
 
22
21
  _BASE = "https://www.alphavantage.co/query"
23
22
 
@@ -128,11 +127,7 @@ class AlphaVantageMarketData(MarketDataProvider):
128
127
 
129
128
  price = Decimal(str(q.get("05. price", "0")))
130
129
  ts = q.get("07. latest trading day")
131
- as_of = (
132
- datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=timezone.utc)
133
- if ts
134
- else datetime.now(timezone.utc)
135
- )
130
+ as_of = datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=UTC) if ts else datetime.now(UTC)
136
131
 
137
132
  return Quote(symbol=symbol.upper(), price=price, as_of=as_of)
138
133
 
@@ -202,7 +197,7 @@ class AlphaVantageMarketData(MarketDataProvider):
202
197
  out: list[Candle] = []
203
198
  for d, vals in list(time_series.items())[:limit]:
204
199
  try:
205
- dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc)
200
+ dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=UTC)
206
201
  ts_ms = int(dt.timestamp() * 1000)
207
202
  out.append(
208
203
  Candle(
@@ -37,13 +37,13 @@ class CCXTCryptoData(CryptoDataProvider):
37
37
  if not self._markets_loaded:
38
38
  self.exchange.load_markets()
39
39
  self._markets_loaded = True
40
- return cast(dict[Any, Any], self.exchange.fetch_ticker(symbol_pair))
40
+ return cast("dict[Any, Any]", self.exchange.fetch_ticker(symbol_pair))
41
41
 
42
42
  def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[list[float]]:
43
43
  if not self._markets_loaded:
44
44
  self.exchange.load_markets()
45
45
  self._markets_loaded = True
46
46
  return cast(
47
- list[list[float]],
47
+ "list[list[float]]",
48
48
  self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
49
49
  )
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import httpx
4
+ from datetime import UTC, datetime
5
5
  from decimal import Decimal
6
- from datetime import datetime, timezone
7
6
 
7
+ import httpx
8
+
9
+ from ...models import Candle, Quote
8
10
  from ..base import CryptoDataProvider
9
- from ...models import Quote, Candle
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
@@ -30,9 +31,7 @@ class CoinGeckoCryptoData(CryptoDataProvider):
30
31
  except Exception as e:
31
32
  logger.warning("CoinGecko ticker fetch failed for %s: %s", symbol_pair, e)
32
33
  price = 0
33
- return Quote(
34
- symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(timezone.utc)
35
- )
34
+ return Quote(symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(UTC))
36
35
 
37
36
  def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[Candle]:
38
37
  # CoinGecko provides market_chart with daily data; map timeframe crudely
@@ -11,8 +11,8 @@ For production, consider Alpha Vantage or other official providers.
11
11
  from __future__ import annotations
12
12
 
13
13
  from collections.abc import Sequence
14
+ from datetime import UTC, datetime
14
15
  from decimal import Decimal
15
- from datetime import datetime, timezone
16
16
 
17
17
  try:
18
18
  from yahooquery import Ticker
@@ -22,8 +22,8 @@ except ImportError: # pragma: no cover
22
22
  HAS_YAHOOQUERY = False
23
23
  Ticker = None
24
24
 
25
+ from ...models import Candle, Quote
25
26
  from .base import MarketDataProvider
26
- from ...models import Quote, Candle
27
27
 
28
28
 
29
29
  def _require_yahooquery() -> None:
@@ -93,9 +93,9 @@ class YahooFinanceMarketData(MarketDataProvider):
93
93
  ts_raw = data.get("regularMarketTime")
94
94
  if ts_raw:
95
95
  # Convert Unix timestamp to datetime
96
- as_of = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
96
+ as_of = datetime.fromtimestamp(ts_raw, tz=UTC)
97
97
  else:
98
- as_of = datetime.now(timezone.utc)
98
+ as_of = datetime.now(UTC)
99
99
 
100
100
  return Quote(
101
101
  symbol=symbol.upper(),
@@ -150,7 +150,7 @@ class YahooFinanceMarketData(MarketDataProvider):
150
150
 
151
151
  # Ensure timezone aware
152
152
  if dt.tzinfo is None:
153
- dt = dt.replace(tzinfo=timezone.utc)
153
+ dt = dt.replace(tzinfo=UTC)
154
154
 
155
155
  ts_ms = int(dt.timestamp() * 1000)
156
156
 
@@ -1,7 +1,7 @@
1
1
  """Tax providers package."""
2
2
 
3
- from .mock import MockTaxProvider
4
3
  from .irs import IRSProvider
4
+ from .mock import MockTaxProvider
5
5
  from .taxbit import TaxBitProvider
6
6
 
7
7
  __all__ = [
@@ -22,8 +22,8 @@ Example:
22
22
  from decimal import Decimal
23
23
 
24
24
  from fin_infra.models.tax import (
25
- TaxDocument,
26
25
  CryptoTaxReport,
26
+ TaxDocument,
27
27
  TaxLiability,
28
28
  )
29
29
  from fin_infra.providers.base import TaxProvider
@@ -14,14 +14,14 @@ from datetime import date, datetime
14
14
  from decimal import Decimal
15
15
 
16
16
  from fin_infra.models.tax import (
17
+ CryptoTaxReport,
18
+ CryptoTransaction,
17
19
  TaxDocument,
18
- TaxFormW2,
19
- TaxForm1099INT,
20
- TaxForm1099DIV,
21
20
  TaxForm1099B,
21
+ TaxForm1099DIV,
22
+ TaxForm1099INT,
22
23
  TaxForm1099MISC,
23
- CryptoTransaction,
24
- CryptoTaxReport,
24
+ TaxFormW2,
25
25
  TaxLiability,
26
26
  )
27
27
  from fin_infra.providers.base import TaxProvider
@@ -21,8 +21,8 @@ Example:
21
21
  from decimal import Decimal
22
22
 
23
23
  from fin_infra.models.tax import (
24
- TaxDocument,
25
24
  CryptoTaxReport,
25
+ TaxDocument,
26
26
  TaxLiability,
27
27
  )
28
28
  from fin_infra.providers.base import TaxProvider
@@ -46,18 +46,18 @@ from .models import (
46
46
  SubscriptionDetection,
47
47
  SubscriptionStats,
48
48
  )
49
- from .summary import (
50
- CancellationOpportunity,
51
- RecurringItem,
52
- RecurringSummary,
53
- get_recurring_summary,
54
- )
55
49
  from .normalizer import (
56
50
  FuzzyMatcher,
57
51
  get_canonical_merchant,
58
52
  is_generic_merchant,
59
53
  normalize_merchant,
60
54
  )
55
+ from .summary import (
56
+ CancellationOpportunity,
57
+ RecurringItem,
58
+ RecurringSummary,
59
+ get_recurring_summary,
60
+ )
61
61
 
62
62
  __all__ = [
63
63
  # Easy builders
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import time
13
13
  from datetime import datetime, timedelta
14
- from typing import TYPE_CHECKING, Any, Optional
14
+ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  from .ease import easy_recurring_detection
17
17
  from .models import (
@@ -24,6 +24,7 @@ from .models import (
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from fastapi import FastAPI
27
+
27
28
  from .detector import RecurringDetector
28
29
 
29
30
 
@@ -35,9 +36,9 @@ def add_recurring_detection(
35
36
  date_tolerance_days: int = 7,
36
37
  enable_llm: bool = False,
37
38
  llm_provider: str = "google",
38
- llm_model: Optional[str] = None,
39
+ llm_model: str | None = None,
39
40
  include_in_schema: bool = True,
40
- ) -> "RecurringDetector":
41
+ ) -> RecurringDetector:
41
42
  """
42
43
  Add recurring transaction detection endpoints to FastAPI app.
43
44
 
@@ -242,7 +243,7 @@ def add_recurring_detection(
242
243
  @router.get("/summary")
243
244
  async def get_recurring_summary(
244
245
  user_id: str,
245
- category_map: Optional[dict[str, str]] = None,
246
+ category_map: dict[str, str] | None = None,
246
247
  ):
247
248
  """
248
249
  Get comprehensive recurring transaction summary.
@@ -17,15 +17,15 @@ from __future__ import annotations
17
17
  import statistics
18
18
  from collections import defaultdict
19
19
  from datetime import datetime, timedelta
20
- from typing import Any, Optional, TYPE_CHECKING
20
+ from typing import TYPE_CHECKING, Any
21
21
 
22
22
  from .models import CadenceType, PatternType, RecurringPattern
23
23
  from .normalizer import get_canonical_merchant, is_generic_merchant
24
24
 
25
25
  if TYPE_CHECKING:
26
- from .normalizers import MerchantNormalizer
27
26
  from .detectors_llm import VariableDetectorLLM
28
27
  from .insights import SubscriptionInsightsGenerator
28
+ from .normalizers import MerchantNormalizer
29
29
 
30
30
 
31
31
  class Transaction:
@@ -59,8 +59,8 @@ class PatternDetector:
59
59
  min_occurrences: int = 3,
60
60
  amount_tolerance: float = 0.02,
61
61
  date_tolerance_days: int = 7,
62
- merchant_normalizer: Optional[MerchantNormalizer] = None,
63
- variable_detector_llm: Optional[VariableDetectorLLM] = None,
62
+ merchant_normalizer: MerchantNormalizer | None = None,
63
+ variable_detector_llm: VariableDetectorLLM | None = None,
64
64
  ):
65
65
  """
66
66
  Initialize pattern detector.
@@ -512,9 +512,9 @@ class RecurringDetector:
512
512
  min_occurrences: int = 3,
513
513
  amount_tolerance: float = 0.02,
514
514
  date_tolerance_days: int = 7,
515
- merchant_normalizer: Optional[MerchantNormalizer] = None,
516
- variable_detector_llm: Optional[VariableDetectorLLM] = None,
517
- insights_generator: Optional[SubscriptionInsightsGenerator] = None,
515
+ merchant_normalizer: MerchantNormalizer | None = None,
516
+ variable_detector_llm: VariableDetectorLLM | None = None,
517
+ insights_generator: SubscriptionInsightsGenerator | None = None,
518
518
  ):
519
519
  """
520
520
  Initialize recurring detector.
@@ -14,7 +14,7 @@ Only called for ambiguous patterns (20-40% variance, ~10% of patterns).
14
14
  from __future__ import annotations
15
15
 
16
16
  import logging
17
- from typing import Any, Optional, cast
17
+ from typing import Any, cast
18
18
 
19
19
  from pydantic import BaseModel, ConfigDict, Field
20
20
 
@@ -41,14 +41,14 @@ class VariableRecurringPattern(BaseModel):
41
41
  ...,
42
42
  description="True if pattern is recurring despite variance",
43
43
  )
44
- cadence: Optional[str] = Field(
44
+ cadence: str | None = Field(
45
45
  None,
46
46
  description=(
47
47
  "Frequency if recurring: monthly, bi-weekly, quarterly, annual, etc. "
48
48
  "None if not recurring."
49
49
  ),
50
50
  )
51
- expected_range: Optional[tuple[float, float]] = Field(
51
+ expected_range: tuple[float, float] | None = Field(
52
52
  None,
53
53
  description=(
54
54
  "Expected amount range (min, max) if recurring. "
@@ -100,7 +100,7 @@ Examples:
100
100
  1. Merchant: "City Electric"
101
101
  Amounts: [$45, $52, $48, $55, $50, $49]
102
102
  Dates: Monthly (15th ±7 days)
103
- → is_recurring: true, cadence: "monthly", range: (40, 60),
103
+ → is_recurring: true, cadence: "monthly", range: (40, 60),
104
104
  reasoning: "Seasonal winter heating variation", confidence: 0.85
105
105
 
106
106
  2. Merchant: "T-Mobile"
@@ -164,7 +164,7 @@ class VariableDetectorLLM:
164
164
  def __init__(
165
165
  self,
166
166
  provider: str = "google",
167
- model_name: Optional[str] = None,
167
+ model_name: str | None = None,
168
168
  max_cost_per_day: float = 0.10,
169
169
  max_cost_per_month: float = 2.00,
170
170
  ):
@@ -293,7 +293,7 @@ class VariableDetectorLLM:
293
293
 
294
294
  # Extract structured output
295
295
  if hasattr(response, "structured") and response.structured:
296
- return cast(VariableRecurringPattern, response.structured)
296
+ return cast("VariableRecurringPattern", response.structured)
297
297
  else:
298
298
  raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
299
299
 
@@ -9,8 +9,6 @@ variable amount detection, and natural language insights.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Optional
13
-
14
12
  from .detector import RecurringDetector
15
13
 
16
14
 
@@ -20,7 +18,7 @@ def easy_recurring_detection(
20
18
  date_tolerance_days: int = 7,
21
19
  enable_llm: bool = False,
22
20
  llm_provider: str = "google",
23
- llm_model: Optional[str] = None,
21
+ llm_model: str | None = None,
24
22
  llm_confidence_threshold: float = 0.8,
25
23
  llm_cache_merchant_ttl: int = 604800, # 7 days
26
24
  llm_cache_insights_ttl: int = 86400, # 24 hours
@@ -216,9 +214,9 @@ def easy_recurring_detection(
216
214
  if enable_llm:
217
215
  # Import V2 components only if needed (avoid circular imports)
218
216
  try:
219
- from .normalizers import MerchantNormalizer
220
217
  from .detectors_llm import VariableDetectorLLM
221
218
  from .insights import SubscriptionInsightsGenerator
219
+ from .normalizers import MerchantNormalizer
222
220
  except ImportError as e:
223
221
  raise ImportError(
224
222
  f"LLM components not available. Install ai-infra: pip install ai-infra. Error: {e}"
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import hashlib
17
17
  import logging
18
- from typing import Any, Optional, cast
18
+ from typing import Any, cast
19
19
 
20
20
  from pydantic import BaseModel, ConfigDict, Field
21
21
 
@@ -61,7 +61,7 @@ class SubscriptionInsights(BaseModel):
61
61
  ge=0.0,
62
62
  description="Total monthly subscription cost",
63
63
  )
64
- potential_savings: Optional[float] = Field(
64
+ potential_savings: float | None = Field(
65
65
  None,
66
66
  ge=0.0,
67
67
  description="Potential monthly savings from recommendations (if applicable)",
@@ -105,20 +105,20 @@ Guidelines:
105
105
 
106
106
  Examples:
107
107
  1. Subscriptions: Netflix $15.99, Hulu $12.99, Disney+ $10.99, Spotify $9.99, Amazon Prime $14.99
108
- → "You have 5 subscriptions totaling $64.95/month. Consider the Disney+ bundle
109
- (Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
108
+ → "You have 5 subscriptions totaling $64.95/month. Consider the Disney+ bundle
109
+ (Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
110
110
  includes Prime Video - you may be able to cancel Netflix or Hulu."
111
111
  → total_monthly_cost: 64.95
112
112
  → potential_savings: 30.00
113
113
 
114
114
  2. Subscriptions: Spotify $9.99, Apple Music $10.99
115
- → "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
115
+ → "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
116
116
  to save $10.99/month."
117
117
  → total_monthly_cost: 20.98
118
118
  → potential_savings: 10.99
119
119
 
120
120
  3. Subscriptions: LA Fitness $40, Planet Fitness $10
121
- → "You have 2 gym memberships totaling $50/month. Consider consolidating to
121
+ → "You have 2 gym memberships totaling $50/month. Consider consolidating to
122
122
  just Planet Fitness to save $40/month."
123
123
  → total_monthly_cost: 50.00
124
124
  → potential_savings: 40.00
@@ -163,7 +163,7 @@ class SubscriptionInsightsGenerator:
163
163
  def __init__(
164
164
  self,
165
165
  provider: str = "google",
166
- model_name: Optional[str] = None,
166
+ model_name: str | None = None,
167
167
  cache_ttl: int = 86400, # 24 hours
168
168
  enable_cache: bool = True,
169
169
  max_cost_per_day: float = 0.10,
@@ -230,7 +230,7 @@ class SubscriptionInsightsGenerator:
230
230
  async def generate(
231
231
  self,
232
232
  subscriptions: list[dict[str, Any]],
233
- user_id: Optional[str] = None,
233
+ user_id: str | None = None,
234
234
  ) -> SubscriptionInsights:
235
235
  """
236
236
  Generate subscription insights with natural language recommendations.
@@ -291,8 +291,8 @@ class SubscriptionInsightsGenerator:
291
291
  async def _get_cached(
292
292
  self,
293
293
  subscriptions: list[dict[str, Any]],
294
- user_id: Optional[str] = None,
295
- ) -> Optional[SubscriptionInsights]:
294
+ user_id: str | None = None,
295
+ ) -> SubscriptionInsights | None:
296
296
  """
297
297
  Get cached insights.
298
298
 
@@ -317,7 +317,7 @@ class SubscriptionInsightsGenerator:
317
317
  self,
318
318
  subscriptions: list[dict[str, Any]],
319
319
  result: SubscriptionInsights,
320
- user_id: Optional[str] = None,
320
+ user_id: str | None = None,
321
321
  ) -> None:
322
322
  """
323
323
  Cache insights result.
@@ -338,7 +338,7 @@ class SubscriptionInsightsGenerator:
338
338
  def _make_cache_key(
339
339
  self,
340
340
  subscriptions: list[dict[str, Any]],
341
- user_id: Optional[str] = None,
341
+ user_id: str | None = None,
342
342
  ) -> str:
343
343
  """
344
344
  Generate cache key for insights.
@@ -384,7 +384,7 @@ class SubscriptionInsightsGenerator:
384
384
 
385
385
  # Extract structured output
386
386
  if hasattr(response, "structured") and response.structured:
387
- return cast(SubscriptionInsights, response.structured)
387
+ return cast("SubscriptionInsights", response.structured)
388
388
  else:
389
389
  raise ValueError("LLM returned no structured output for insights")
390
390
 
@@ -166,7 +166,7 @@ class FuzzyMatcher:
166
166
  norm2 = normalize_merchant(name2)
167
167
 
168
168
  similarity = fuzz.token_sort_ratio(norm1, norm2)
169
- return cast(bool, similarity >= self.similarity_threshold)
169
+ return cast("bool", similarity >= self.similarity_threshold)
170
170
 
171
171
  def group_merchants(self, merchants: list[str]) -> dict[str, list[str]]:
172
172
  """
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import hashlib
18
18
  import logging
19
- from typing import Any, Optional, cast
19
+ from typing import Any, cast
20
20
 
21
21
  from pydantic import BaseModel, ConfigDict, Field
22
22
 
@@ -145,7 +145,7 @@ class MerchantNormalizer:
145
145
  def __init__(
146
146
  self,
147
147
  provider: str = "google",
148
- model_name: Optional[str] = None,
148
+ model_name: str | None = None,
149
149
  cache_ttl: int = 604800, # 7 days
150
150
  enable_cache: bool = True,
151
151
  confidence_threshold: float = 0.8,
@@ -284,7 +284,7 @@ class MerchantNormalizer:
284
284
  logger.error(f"LLM normalization failed for '{merchant_name}': {e}")
285
285
  return self._fallback_normalize(merchant_name, fallback_confidence)
286
286
 
287
- async def _get_cached(self, merchant_name: str) -> Optional[MerchantNormalized]:
287
+ async def _get_cached(self, merchant_name: str) -> MerchantNormalized | None:
288
288
  """
289
289
  Get cached normalization result.
290
290
 
@@ -355,7 +355,7 @@ class MerchantNormalizer:
355
355
 
356
356
  # Extract structured output
357
357
  if hasattr(response, "structured") and response.structured:
358
- return cast(MerchantNormalized, response.structured)
358
+ return cast("MerchantNormalized", response.structured)
359
359
  else:
360
360
  raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
361
361
 
@@ -33,14 +33,12 @@ Integration with svc-infra:
33
33
 
34
34
  from __future__ import annotations
35
35
 
36
- from typing import Optional
37
- from datetime import datetime
38
36
  from collections import defaultdict
37
+ from datetime import datetime
39
38
 
40
- from pydantic import BaseModel, Field, ConfigDict
41
-
42
- from fin_infra.recurring.models import RecurringPattern, PatternType
39
+ from pydantic import BaseModel, ConfigDict, Field
43
40
 
41
+ from fin_infra.recurring.models import PatternType, RecurringPattern
44
42
 
45
43
  __all__ = [
46
44
  "RecurringItem",
@@ -254,7 +252,7 @@ def _identify_cancellation_opportunities(
254
252
  def get_recurring_summary(
255
253
  user_id: str,
256
254
  patterns: list[RecurringPattern],
257
- category_map: Optional[dict[str, str]] = None,
255
+ category_map: dict[str, str] | None = None,
258
256
  ) -> RecurringSummary:
259
257
  """Generate a comprehensive recurring transaction summary for a user.
260
258
 
@@ -19,10 +19,10 @@ Typical usage:
19
19
  from __future__ import annotations
20
20
 
21
21
  from pathlib import Path
22
- from typing import Any, Optional
22
+ from typing import Any
23
23
 
24
24
  # Use svc-infra's scaffold utilities to avoid duplication
25
- from svc_infra.utils import render_template, write, ensure_init_py
25
+ from svc_infra.utils import ensure_init_py, render_template, write
26
26
 
27
27
 
28
28
  def scaffold_budgets_core(
@@ -31,9 +31,9 @@ def scaffold_budgets_core(
31
31
  include_soft_delete: bool = False,
32
32
  with_repository: bool = True,
33
33
  overwrite: bool = False,
34
- models_filename: Optional[str] = None,
35
- schemas_filename: Optional[str] = None,
36
- repository_filename: Optional[str] = None,
34
+ models_filename: str | None = None,
35
+ schemas_filename: str | None = None,
36
+ repository_filename: str | None = None,
37
37
  ) -> dict[str, Any]:
38
38
  """Generate budget persistence code from templates.
39
39
 
@@ -229,7 +229,7 @@ def _tenant_field_schema_read() -> str:
229
229
  def _generate_init_content(
230
230
  models_file: str,
231
231
  schemas_file: str,
232
- repo_file: Optional[str],
232
+ repo_file: str | None,
233
233
  ) -> str:
234
234
  """Generate __init__.py content with re-exports.
235
235
 
@@ -20,9 +20,9 @@ from pathlib import Path
20
20
  from typing import Any
21
21
 
22
22
  from svc_infra.utils import (
23
+ ensure_init_py,
23
24
  render_template,
24
25
  write,
25
- ensure_init_py,
26
26
  )
27
27
 
28
28