fin-infra 0.1.69__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 (108) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +21 -21
  4. fin_infra/analytics/ease.py +19 -20
  5. fin_infra/analytics/portfolio.py +6 -6
  6. fin_infra/analytics/projections.py +1 -3
  7. fin_infra/banking/__init__.py +27 -28
  8. fin_infra/banking/history.py +8 -9
  9. fin_infra/banking/utils.py +27 -26
  10. fin_infra/brokerage/__init__.py +22 -24
  11. fin_infra/budgets/__init__.py +3 -3
  12. fin_infra/budgets/add.py +16 -17
  13. fin_infra/budgets/alerts.py +3 -3
  14. fin_infra/budgets/tracker.py +2 -2
  15. fin_infra/cashflows/__init__.py +3 -3
  16. fin_infra/cashflows/core.py +1 -1
  17. fin_infra/categorization/add.py +2 -3
  18. fin_infra/categorization/engine.py +17 -14
  19. fin_infra/categorization/llm_layer.py +7 -6
  20. fin_infra/categorization/rules.py +2 -4
  21. fin_infra/categorization/taxonomy.py +2 -2
  22. fin_infra/chat/__init__.py +6 -6
  23. fin_infra/chat/planning.py +0 -1
  24. fin_infra/cli/cmds/scaffold_cmds.py +10 -11
  25. fin_infra/clients/__init__.py +23 -1
  26. fin_infra/clients/base.py +1 -1
  27. fin_infra/clients/plaid.py +2 -2
  28. fin_infra/compliance/__init__.py +5 -4
  29. fin_infra/credit/add.py +6 -7
  30. fin_infra/credit/experian/auth.py +2 -2
  31. fin_infra/credit/experian/client.py +1 -1
  32. fin_infra/credit/experian/provider.py +4 -4
  33. fin_infra/crypto/__init__.py +7 -9
  34. fin_infra/crypto/insights.py +4 -3
  35. fin_infra/documents/add.py +6 -8
  36. fin_infra/documents/analysis.py +9 -9
  37. fin_infra/documents/ease.py +14 -14
  38. fin_infra/documents/models.py +4 -4
  39. fin_infra/documents/ocr.py +7 -7
  40. fin_infra/documents/storage.py +21 -13
  41. fin_infra/exceptions.py +0 -1
  42. fin_infra/goals/__init__.py +8 -8
  43. fin_infra/goals/add.py +36 -36
  44. fin_infra/goals/funding.py +4 -6
  45. fin_infra/goals/management.py +2 -3
  46. fin_infra/goals/milestones.py +1 -2
  47. fin_infra/goals/models.py +7 -11
  48. fin_infra/insights/__init__.py +6 -3
  49. fin_infra/insights/aggregator.py +1 -1
  50. fin_infra/investments/__init__.py +1 -1
  51. fin_infra/investments/add.py +23 -23
  52. fin_infra/investments/models.py +5 -5
  53. fin_infra/investments/providers/base.py +8 -9
  54. fin_infra/investments/providers/plaid.py +52 -26
  55. fin_infra/investments/providers/snaptrade.py +19 -19
  56. fin_infra/markets/__init__.py +5 -3
  57. fin_infra/models/__init__.py +10 -10
  58. fin_infra/models/brokerage.py +2 -1
  59. fin_infra/models/candle.py +1 -0
  60. fin_infra/models/money.py +1 -0
  61. fin_infra/models/quotes.py +4 -3
  62. fin_infra/models/tax.py +2 -1
  63. fin_infra/models/transactions.py +3 -4
  64. fin_infra/net_worth/__init__.py +7 -0
  65. fin_infra/net_worth/aggregator.py +4 -2
  66. fin_infra/net_worth/insights.py +0 -1
  67. fin_infra/normalization/__init__.py +2 -2
  68. fin_infra/normalization/providers/exchangerate.py +5 -5
  69. fin_infra/obs/classifier.py +1 -1
  70. fin_infra/providers/banking/plaid_client.py +5 -5
  71. fin_infra/providers/banking/teller_client.py +7 -6
  72. fin_infra/providers/base.py +27 -2
  73. fin_infra/providers/brokerage/alpaca.py +3 -3
  74. fin_infra/providers/market/alphavantage.py +6 -11
  75. fin_infra/providers/market/ccxt_crypto.py +19 -3
  76. fin_infra/providers/market/coingecko.py +5 -6
  77. fin_infra/providers/market/yahoo.py +23 -8
  78. fin_infra/providers/tax/__init__.py +1 -1
  79. fin_infra/providers/tax/irs.py +1 -1
  80. fin_infra/providers/tax/mock.py +5 -5
  81. fin_infra/providers/tax/taxbit.py +1 -1
  82. fin_infra/recurring/__init__.py +6 -6
  83. fin_infra/recurring/add.py +5 -4
  84. fin_infra/recurring/detector.py +7 -7
  85. fin_infra/recurring/detectors_llm.py +6 -6
  86. fin_infra/recurring/ease.py +2 -4
  87. fin_infra/recurring/insights.py +13 -13
  88. fin_infra/recurring/normalizer.py +1 -1
  89. fin_infra/recurring/normalizers.py +4 -4
  90. fin_infra/recurring/summary.py +13 -15
  91. fin_infra/scaffold/budgets.py +9 -9
  92. fin_infra/scaffold/goals.py +5 -5
  93. fin_infra/security/__init__.py +8 -8
  94. fin_infra/security/encryption.py +6 -6
  95. fin_infra/security/models.py +7 -7
  96. fin_infra/security/pii_filter.py +6 -6
  97. fin_infra/settings.py +2 -1
  98. fin_infra/tax/__init__.py +1 -1
  99. fin_infra/tax/add.py +3 -2
  100. fin_infra/tax/tlh.py +5 -5
  101. fin_infra/utils/http.py +4 -3
  102. fin_infra/utils/retry.py +2 -1
  103. {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -8
  104. fin_infra-0.1.82.dist-info/RECORD +180 -0
  105. fin_infra-0.1.69.dist-info/RECORD +0 -180
  106. {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  107. {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  108. {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
@@ -30,6 +30,7 @@ print(f"Net Worth: ${snapshot.total_net_worth:,.2f}")
30
30
  """
31
31
 
32
32
  import asyncio
33
+ import logging
33
34
  import uuid
34
35
  from datetime import datetime
35
36
  from typing import Any
@@ -47,6 +48,8 @@ from fin_infra.net_worth.models import (
47
48
  NetWorthSnapshot,
48
49
  )
49
50
 
51
+ logger = logging.getLogger(__name__)
52
+
50
53
 
51
54
  class NetWorthAggregator:
52
55
  """
@@ -219,8 +222,7 @@ class NetWorthAggregator:
219
222
 
220
223
  for i, result in enumerate(results):
221
224
  if isinstance(result, BaseException):
222
- # Log error but continue (graceful degradation)
223
- print(f"Provider {providers_used[i]} failed: {result}")
225
+ logger.warning("Provider %s failed: %s", providers_used[i], result)
224
226
  continue
225
227
 
226
228
  # result is now tuple[list[AssetDetail], list[LiabilityDetail]]
@@ -31,7 +31,6 @@ from typing import Any
31
31
 
32
32
  from pydantic import BaseModel, Field
33
33
 
34
-
35
34
  # ============================================================================
36
35
  # Pydantic Schemas (Structured Output)
37
36
  # ============================================================================
@@ -116,11 +116,11 @@ def add_normalization(
116
116
  - Scoped docs at {prefix}/docs for standalone documentation
117
117
  """
118
118
  # Import FastAPI dependencies
119
- from fastapi import Query, HTTPException
119
+ from fastapi import HTTPException, Query
120
+ from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
120
121
 
121
122
  # Import svc-infra public router (no auth - utility endpoints)
122
123
  from svc_infra.api.fastapi.dual.public import public_router
123
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
124
124
 
125
125
  # Get normalization services
126
126
  resolver, converter = easy_normalization(api_key=api_key)
@@ -2,7 +2,7 @@
2
2
 
3
3
  import os
4
4
  from datetime import date as DateType
5
- from typing import Optional, cast
5
+ from typing import cast
6
6
 
7
7
  import httpx
8
8
 
@@ -19,7 +19,7 @@ __all__ = [
19
19
  class ExchangeRateClient:
20
20
  """Client for exchangerate-api.io API."""
21
21
 
22
- def __init__(self, api_key: Optional[str] = None):
22
+ def __init__(self, api_key: str | None = None):
23
23
  """
24
24
  Initialize exchange rate client.
25
25
 
@@ -66,10 +66,10 @@ class ExchangeRateClient:
66
66
  raise ExchangeRateAPIError(
67
67
  f"API returned error: {data.get('error-type', 'unknown')}"
68
68
  )
69
- return cast(dict[str, float], data["conversion_rates"])
69
+ return cast("dict[str, float]", data["conversion_rates"])
70
70
  else:
71
71
  # Free tier response format
72
- return cast(dict[str, float], data["rates"])
72
+ return cast("dict[str, float]", data["rates"])
73
73
 
74
74
  except httpx.HTTPError as e:
75
75
  raise ExchangeRateAPIError(f"HTTP error fetching rates: {e}")
@@ -77,7 +77,7 @@ class ExchangeRateClient:
77
77
  raise ExchangeRateAPIError(f"Invalid API response: {e}")
78
78
 
79
79
  async def get_rate(
80
- self, from_currency: str, to_currency: str, date: Optional[DateType] = None
80
+ self, from_currency: str, to_currency: str, date: DateType | None = None
81
81
  ) -> ExchangeRate:
82
82
  """
83
83
  Get exchange rate between two currencies.
@@ -37,7 +37,7 @@ Usage:
37
37
 
38
38
  from __future__ import annotations
39
39
 
40
- from typing import Callable
40
+ from collections.abc import Callable
41
41
 
42
42
  # Financial capability prefix patterns (extensible)
43
43
  FINANCIAL_ROUTE_PREFIXES = (
@@ -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):
@@ -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
 
@@ -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}"