fin-infra 0.1.69__py3-none-any.whl → 0.4.0__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 (131) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +24 -24
  4. fin_infra/analytics/cash_flow.py +3 -3
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/models.py +5 -5
  7. fin_infra/analytics/portfolio.py +18 -18
  8. fin_infra/analytics/projections.py +1 -3
  9. fin_infra/analytics/spending.py +4 -5
  10. fin_infra/banking/__init__.py +27 -28
  11. fin_infra/banking/history.py +12 -13
  12. fin_infra/banking/utils.py +27 -26
  13. fin_infra/brokerage/__init__.py +29 -31
  14. fin_infra/budgets/__init__.py +3 -3
  15. fin_infra/budgets/add.py +16 -17
  16. fin_infra/budgets/alerts.py +4 -4
  17. fin_infra/budgets/ease.py +1 -2
  18. fin_infra/budgets/models.py +1 -2
  19. fin_infra/budgets/templates.py +4 -4
  20. fin_infra/budgets/tracker.py +4 -4
  21. fin_infra/cashflows/__init__.py +3 -3
  22. fin_infra/cashflows/core.py +1 -1
  23. fin_infra/categorization/__init__.py +1 -1
  24. fin_infra/categorization/add.py +2 -3
  25. fin_infra/categorization/ease.py +3 -3
  26. fin_infra/categorization/engine.py +18 -15
  27. fin_infra/categorization/llm_layer.py +13 -10
  28. fin_infra/categorization/models.py +3 -4
  29. fin_infra/categorization/rules.py +2 -4
  30. fin_infra/categorization/taxonomy.py +2 -2
  31. fin_infra/chat/__init__.py +6 -6
  32. fin_infra/chat/planning.py +1 -2
  33. fin_infra/cli/cmds/scaffold_cmds.py +16 -17
  34. fin_infra/clients/__init__.py +23 -1
  35. fin_infra/clients/base.py +1 -1
  36. fin_infra/clients/plaid.py +2 -2
  37. fin_infra/compliance/__init__.py +5 -4
  38. fin_infra/credit/add.py +6 -7
  39. fin_infra/credit/experian/auth.py +2 -2
  40. fin_infra/credit/experian/client.py +1 -1
  41. fin_infra/credit/experian/parser.py +5 -5
  42. fin_infra/credit/experian/provider.py +4 -4
  43. fin_infra/crypto/__init__.py +9 -11
  44. fin_infra/crypto/insights.py +4 -3
  45. fin_infra/documents/add.py +6 -8
  46. fin_infra/documents/analysis.py +9 -9
  47. fin_infra/documents/ease.py +14 -14
  48. fin_infra/documents/models.py +5 -6
  49. fin_infra/documents/ocr.py +7 -7
  50. fin_infra/documents/storage.py +21 -13
  51. fin_infra/exceptions.py +0 -1
  52. fin_infra/goals/__init__.py +8 -8
  53. fin_infra/goals/add.py +36 -36
  54. fin_infra/goals/funding.py +4 -6
  55. fin_infra/goals/management.py +5 -6
  56. fin_infra/goals/milestones.py +7 -8
  57. fin_infra/goals/models.py +9 -13
  58. fin_infra/insights/__init__.py +6 -3
  59. fin_infra/insights/aggregator.py +1 -1
  60. fin_infra/investments/__init__.py +3 -3
  61. fin_infra/investments/add.py +23 -23
  62. fin_infra/investments/ease.py +2 -2
  63. fin_infra/investments/models.py +27 -29
  64. fin_infra/investments/providers/base.py +12 -13
  65. fin_infra/investments/providers/plaid.py +52 -26
  66. fin_infra/investments/providers/snaptrade.py +19 -19
  67. fin_infra/investments/scaffold_templates/README.md +17 -17
  68. fin_infra/markets/__init__.py +7 -5
  69. fin_infra/models/__init__.py +10 -10
  70. fin_infra/models/accounts.py +4 -5
  71. fin_infra/models/brokerage.py +2 -1
  72. fin_infra/models/candle.py +1 -0
  73. fin_infra/models/money.py +1 -0
  74. fin_infra/models/quotes.py +4 -3
  75. fin_infra/models/tax.py +2 -1
  76. fin_infra/models/transactions.py +4 -5
  77. fin_infra/net_worth/__init__.py +8 -1
  78. fin_infra/net_worth/aggregator.py +5 -3
  79. fin_infra/net_worth/calculator.py +1 -1
  80. fin_infra/net_worth/insights.py +7 -8
  81. fin_infra/normalization/__init__.py +4 -4
  82. fin_infra/normalization/currency_converter.py +7 -8
  83. fin_infra/normalization/models.py +9 -10
  84. fin_infra/normalization/providers/exchangerate.py +5 -5
  85. fin_infra/normalization/providers/static_mappings.py +1 -1
  86. fin_infra/normalization/symbol_resolver.py +3 -4
  87. fin_infra/obs/classifier.py +3 -3
  88. fin_infra/providers/banking/plaid_client.py +5 -5
  89. fin_infra/providers/banking/teller_client.py +7 -6
  90. fin_infra/providers/base.py +27 -2
  91. fin_infra/providers/brokerage/alpaca.py +4 -4
  92. fin_infra/providers/market/alphavantage.py +6 -11
  93. fin_infra/providers/market/ccxt_crypto.py +19 -3
  94. fin_infra/providers/market/coingecko.py +5 -6
  95. fin_infra/providers/market/yahoo.py +23 -8
  96. fin_infra/providers/tax/__init__.py +1 -1
  97. fin_infra/providers/tax/irs.py +1 -1
  98. fin_infra/providers/tax/mock.py +5 -5
  99. fin_infra/providers/tax/taxbit.py +1 -1
  100. fin_infra/recurring/__init__.py +6 -6
  101. fin_infra/recurring/add.py +6 -5
  102. fin_infra/recurring/detector.py +7 -7
  103. fin_infra/recurring/detectors_llm.py +10 -10
  104. fin_infra/recurring/ease.py +6 -8
  105. fin_infra/recurring/insights.py +25 -24
  106. fin_infra/recurring/normalizer.py +7 -7
  107. fin_infra/recurring/normalizers.py +31 -30
  108. fin_infra/recurring/summary.py +13 -15
  109. fin_infra/scaffold/budgets.py +9 -9
  110. fin_infra/scaffold/goals.py +9 -9
  111. fin_infra/security/__init__.py +8 -8
  112. fin_infra/security/add.py +1 -2
  113. fin_infra/security/audit.py +6 -7
  114. fin_infra/security/encryption.py +6 -6
  115. fin_infra/security/models.py +7 -7
  116. fin_infra/security/pii_filter.py +16 -16
  117. fin_infra/security/token_store.py +2 -3
  118. fin_infra/settings.py +2 -1
  119. fin_infra/tax/__init__.py +1 -1
  120. fin_infra/tax/add.py +5 -4
  121. fin_infra/tax/tlh.py +10 -10
  122. fin_infra/utils/__init__.py +15 -1
  123. fin_infra/utils/deprecation.py +161 -0
  124. fin_infra/utils/http.py +4 -3
  125. fin_infra/utils/retry.py +2 -1
  126. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
  127. fin_infra-0.4.0.dist-info/RECORD +181 -0
  128. fin_infra-0.1.69.dist-info/RECORD +0 -180
  129. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
  130. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
  131. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -7,15 +7,15 @@ from typing import Any, cast
7
7
  try:
8
8
  import plaid
9
9
  from plaid.api import plaid_api
10
+ from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
11
+ from plaid.model.accounts_get_request import AccountsGetRequest
10
12
  from plaid.model.country_code import CountryCode
13
+ from plaid.model.identity_get_request import IdentityGetRequest
11
14
  from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
12
15
  from plaid.model.link_token_create_request import LinkTokenCreateRequest
13
16
  from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
14
17
  from plaid.model.products import Products
15
18
  from plaid.model.transactions_get_request import TransactionsGetRequest
16
- from plaid.model.accounts_get_request import AccountsGetRequest
17
- from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
18
- from plaid.model.identity_get_request import IdentityGetRequest
19
19
 
20
20
  PLAID_AVAILABLE = True
21
21
  except Exception: # pragma: no cover - dynamic import guard
@@ -97,7 +97,7 @@ class PlaidClient(BankingProvider):
97
97
  language="en",
98
98
  )
99
99
  response = self.client.link_token_create(request)
100
- return cast(str, response["link_token"])
100
+ return cast("str", response["link_token"])
101
101
 
102
102
  def exchange_public_token(self, public_token: str) -> dict:
103
103
  request = ItemPublicTokenExchangeRequest(public_token=public_token)
@@ -151,4 +151,4 @@ class PlaidClient(BankingProvider):
151
151
  """Fetch identity/account holder information."""
152
152
  request = IdentityGetRequest(access_token=access_token)
153
153
  response = self.client.identity_get(request)
154
- return cast(dict[Any, Any], response.to_dict())
154
+ return cast("dict[Any, Any]", response.to_dict())
@@ -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."""
@@ -1,9 +1,34 @@
1
+ """Base provider ABCs for fin-infra.
2
+
3
+ This module defines abstract base classes for all financial data providers.
4
+ These are the canonical ABCs - use these instead of fin_infra.clients.
5
+
6
+ Sync vs Async Pattern:
7
+ Most providers use SYNCHRONOUS methods for simplicity. The exceptions are:
8
+ - InvestmentProvider: Uses async methods (get_holdings, get_investment_accounts)
9
+
10
+ If you need async, wrap sync providers with asyncio.to_thread():
11
+ import asyncio
12
+ result = await asyncio.to_thread(provider.quote, "AAPL")
13
+
14
+ Provider Categories:
15
+ - MarketDataProvider: Stock/equity quotes and historical data
16
+ - CryptoDataProvider: Cryptocurrency market data
17
+ - BankingProvider: Bank account aggregation (Plaid, Teller, MX)
18
+ - BrokerageProvider: Trading operations (Alpaca, Interactive Brokers)
19
+ - CreditProvider: Credit scores and reports
20
+ - TaxProvider: Tax documents and calculations
21
+ - IdentityProvider: Identity verification
22
+ - InvestmentProvider: Investment holdings (async)
23
+ """
24
+
1
25
  from __future__ import annotations
2
26
 
3
27
  from abc import ABC, abstractmethod
4
- from typing import Any, Iterable, Sequence
28
+ from collections.abc import Iterable, Sequence
29
+ from typing import Any
5
30
 
6
- from ..models import Quote, Candle
31
+ from ..models import Candle, Quote
7
32
 
8
33
 
9
34
  class MarketDataProvider(ABC):
@@ -1,6 +1,6 @@
1
1
  """Alpaca brokerage provider for paper and live trading.
2
2
 
3
- ⚠️ IMPORTANT: This module provides real trading capabilities. Always use paper trading
3
+ [!] IMPORTANT: This module provides real trading capabilities. Always use paper trading
4
4
  mode for development and testing. Live trading requires explicit opt-in.
5
5
  """
6
6
 
@@ -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)
@@ -8,16 +8,15 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  import time
11
- from typing import Sequence
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(
@@ -2,15 +2,31 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, cast
4
4
 
5
- import ccxt
5
+ try:
6
+ import ccxt
7
+
8
+ HAS_CCXT = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_CCXT = False
11
+ ccxt = None
6
12
 
7
13
  from ..base import CryptoDataProvider
8
14
 
9
15
 
16
+ def _require_ccxt() -> None:
17
+ """Raise ImportError if ccxt is not installed."""
18
+ if not HAS_CCXT:
19
+ raise ImportError(
20
+ "Crypto exchange support requires the 'ccxt' package. "
21
+ "Install with: pip install fin-infra[crypto] or pip install fin-infra[markets]"
22
+ )
23
+
24
+
10
25
  class CCXTCryptoData(CryptoDataProvider):
11
26
  """Exchange-agnostic crypto market data using CCXT."""
12
27
 
13
28
  def __init__(self, exchange: str = "binance") -> None:
29
+ _require_ccxt()
14
30
  if not hasattr(ccxt, exchange):
15
31
  raise ValueError(f"Unknown exchange '{exchange}' in ccxt")
16
32
  self.exchange = getattr(ccxt, exchange)()
@@ -21,13 +37,13 @@ class CCXTCryptoData(CryptoDataProvider):
21
37
  if not self._markets_loaded:
22
38
  self.exchange.load_markets()
23
39
  self._markets_loaded = True
24
- return cast(dict[Any, Any], self.exchange.fetch_ticker(symbol_pair))
40
+ return cast("dict[Any, Any]", self.exchange.fetch_ticker(symbol_pair))
25
41
 
26
42
  def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[list[float]]:
27
43
  if not self._markets_loaded:
28
44
  self.exchange.load_markets()
29
45
  self._markets_loaded = True
30
46
  return cast(
31
- list[list[float]],
47
+ "list[list[float]]",
32
48
  self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
33
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
@@ -10,14 +10,29 @@ For production, consider Alpha Vantage or other official providers.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import Sequence
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
- from yahooquery import Ticker
17
+ try:
18
+ from yahooquery import Ticker
18
19
 
20
+ HAS_YAHOOQUERY = True
21
+ except ImportError: # pragma: no cover
22
+ HAS_YAHOOQUERY = False
23
+ Ticker = None
24
+
25
+ from ...models import Candle, Quote
19
26
  from .base import MarketDataProvider
20
- from ...models import Quote, Candle
27
+
28
+
29
+ def _require_yahooquery() -> None:
30
+ """Raise ImportError if yahooquery is not installed."""
31
+ if not HAS_YAHOOQUERY:
32
+ raise ImportError(
33
+ "Yahoo Finance support requires the 'yahooquery' package. "
34
+ "Install with: pip install fin-infra[yahoo] or pip install fin-infra[markets]"
35
+ )
21
36
 
22
37
 
23
38
  class YahooFinanceMarketData(MarketDataProvider):
@@ -42,7 +57,7 @@ class YahooFinanceMarketData(MarketDataProvider):
42
57
 
43
58
  def __init__(self) -> None:
44
59
  """Initialize Yahoo Finance provider (no configuration needed)."""
45
- pass
60
+ _require_yahooquery()
46
61
 
47
62
  def quote(self, symbol: str) -> Quote:
48
63
  """Get real-time quote for a symbol.
@@ -78,9 +93,9 @@ class YahooFinanceMarketData(MarketDataProvider):
78
93
  ts_raw = data.get("regularMarketTime")
79
94
  if ts_raw:
80
95
  # Convert Unix timestamp to datetime
81
- as_of = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
96
+ as_of = datetime.fromtimestamp(ts_raw, tz=UTC)
82
97
  else:
83
- as_of = datetime.now(timezone.utc)
98
+ as_of = datetime.now(UTC)
84
99
 
85
100
  return Quote(
86
101
  symbol=symbol.upper(),
@@ -135,7 +150,7 @@ class YahooFinanceMarketData(MarketDataProvider):
135
150
 
136
151
  # Ensure timezone aware
137
152
  if dt.tzinfo is None:
138
- dt = dt.replace(tzinfo=timezone.utc)
153
+ dt = dt.replace(tzinfo=UTC)
139
154
 
140
155
  ts_ms = int(dt.timestamp() * 1000)
141
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
 
@@ -108,7 +109,7 @@ def add_recurring_detection(
108
109
  Detect recurring patterns in transaction history.
109
110
 
110
111
  Analyzes transaction history for recurring subscriptions and bills using
111
- 3-layer hybrid detection (fixed variable irregular).
112
+ 3-layer hybrid detection (fixed -> variable -> irregular).
112
113
 
113
114
  **Example Request:**
114
115
  ```json
@@ -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,30 +100,30 @@ 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"
107
107
  Amounts: [$50, $50, $50, $78, $50, $50]
108
108
  Dates: Monthly (20th ±3 days)
109
- is_recurring: true, cadence: "monthly", range: (50, 80),
109
+ -> is_recurring: true, cadence: "monthly", range: (50, 80),
110
110
  reasoning: "Occasional overage charge spike", confidence: 0.80
111
111
 
112
112
  3. Merchant: "Random Store"
113
113
  Amounts: [$10, $45, $23, $67, $12]
114
114
  Dates: Irregular
115
- is_recurring: false, reasoning: "Too much variance, no pattern", confidence: 0.95
115
+ -> is_recurring: false, reasoning: "Too much variance, no pattern", confidence: 0.95
116
116
 
117
117
  4. Merchant: "Gas Company"
118
118
  Amounts: [$45, $48, $50, $52, $120, $115]
119
119
  Dates: Monthly
120
- is_recurring: true, cadence: "monthly", range: (40, 120),
120
+ -> is_recurring: true, cadence: "monthly", range: (40, 120),
121
121
  reasoning: "Winter heating season doubles bill", confidence: 0.80
122
122
 
123
123
  5. Merchant: "Gym Membership"
124
124
  Amounts: [$40, $40, $0, $40, $40]
125
125
  Dates: Monthly
126
- is_recurring: true, cadence: "monthly", range: (0, 40),
126
+ -> is_recurring: true, cadence: "monthly", range: (0, 40),
127
127
  reasoning: "Annual fee waived one month", confidence: 0.75
128
128
 
129
129
  Output format (JSON):
@@ -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
@@ -45,7 +43,7 @@ def easy_recurring_detection(
45
43
  V2 Parameters (LLM Enhancement):
46
44
  enable_llm: Enable LLM for merchant normalization and variable detection (default: False)
47
45
  When False, uses V1 pattern-based only (fast, $0 cost)
48
- When True, uses 4-layer hybrid (RapidFuzz LLM normalization Statistical LLM variable detection)
46
+ When True, uses 4-layer hybrid (RapidFuzz -> LLM normalization -> Statistical -> LLM variable detection)
49
47
  llm_provider: LLM provider to use (default: "google")
50
48
  Options: "google" (Gemini 2.0 Flash, cheapest), "openai" (GPT-4o-mini), "anthropic" (Claude 3.5 Haiku)
51
49
  llm_model: Override default model for provider (default: None)
@@ -56,9 +54,9 @@ def easy_recurring_detection(
56
54
  Higher values (0.9) call LLM more often (more accurate, higher cost)
57
55
  Lower values (0.7) call LLM less often (less accurate, lower cost)
58
56
  llm_cache_merchant_ttl: Merchant normalization cache TTL in seconds (default: 604800 = 7 days)
59
- 95% cache hit rate expected most requests <1ms
57
+ 95% cache hit rate expected -> most requests <1ms
60
58
  llm_cache_insights_ttl: Insights generation cache TTL in seconds (default: 86400 = 24 hours)
61
- 80% cache hit rate expected most requests <1ms
59
+ 80% cache hit rate expected -> most requests <1ms
62
60
  llm_max_cost_per_day: Daily budget cap in USD (default: $0.10)
63
61
  Supports ~33k normalizations or ~1k variable detections per day
64
62
  Sufficient for 100k+ users
@@ -82,7 +80,7 @@ def easy_recurring_detection(
82
80
  >>> # V2: LLM-enhanced detection (better accuracy, minimal cost)
83
81
  >>> detector = easy_recurring_detection(enable_llm=True)
84
82
  >>> patterns = detector.detect_patterns(transactions)
85
- >>> # Merchant normalization: "NFLX*SUB" "Netflix" (90-95% accuracy)
83
+ >>> # Merchant normalization: "NFLX*SUB" -> "Netflix" (90-95% accuracy)
86
84
  >>> # Variable detection: Utility bills with seasonal variance (85-88% accuracy)
87
85
  >>> # Cost: ~$0.003/user/year with caching
88
86
 
@@ -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}"