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.
- fin_infra/analytics/__init__.py +3 -2
- fin_infra/analytics/add.py +21 -21
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +6 -6
- fin_infra/analytics/projections.py +1 -3
- fin_infra/banking/__init__.py +27 -27
- fin_infra/banking/history.py +4 -5
- fin_infra/banking/utils.py +19 -18
- fin_infra/brokerage/__init__.py +22 -24
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/cashflows/__init__.py +2 -2
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/engine.py +6 -6
- fin_infra/categorization/llm_layer.py +6 -5
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +5 -5
- fin_infra/chat/planning.py +0 -1
- fin_infra/cli/cmds/scaffold_cmds.py +10 -11
- fin_infra/clients/plaid.py +1 -1
- fin_infra/compliance/__init__.py +5 -5
- fin_infra/credit/add.py +6 -7
- fin_infra/credit/experian/auth.py +2 -2
- fin_infra/credit/experian/client.py +1 -1
- fin_infra/credit/experian/provider.py +4 -4
- fin_infra/crypto/__init__.py +7 -9
- fin_infra/documents/add.py +6 -8
- fin_infra/documents/analysis.py +8 -8
- fin_infra/documents/ease.py +14 -14
- fin_infra/documents/ocr.py +7 -7
- fin_infra/documents/storage.py +21 -13
- fin_infra/exceptions.py +0 -1
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +30 -30
- fin_infra/goals/funding.py +1 -1
- fin_infra/goals/management.py +2 -3
- fin_infra/goals/milestones.py +1 -2
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +2 -2
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +1 -1
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/providers/base.py +2 -3
- fin_infra/investments/providers/plaid.py +9 -9
- fin_infra/investments/providers/snaptrade.py +10 -10
- fin_infra/markets/__init__.py +1 -1
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +3 -4
- fin_infra/net_worth/insights.py +0 -1
- fin_infra/normalization/__init__.py +2 -2
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/providers/banking/plaid_client.py +5 -5
- fin_infra/providers/banking/teller_client.py +7 -6
- fin_infra/providers/base.py +1 -1
- fin_infra/providers/brokerage/alpaca.py +3 -3
- fin_infra/providers/market/alphavantage.py +5 -10
- fin_infra/providers/market/ccxt_crypto.py +2 -2
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +5 -5
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +5 -5
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +5 -4
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +6 -6
- fin_infra/recurring/ease.py +2 -4
- fin_infra/recurring/insights.py +13 -13
- fin_infra/recurring/normalizer.py +1 -1
- fin_infra/recurring/normalizers.py +4 -4
- fin_infra/recurring/summary.py +4 -6
- fin_infra/scaffold/budgets.py +6 -6
- fin_infra/scaffold/goals.py +1 -1
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +1 -1
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/METADATA +1 -1
- fin_infra-0.1.83.dist-info/RECORD +180 -0
- fin_infra-0.1.81.dist-info/RECORD +0 -180
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.81.dist-info → fin_infra-0.1.83.dist-info}/WHEEL +0 -0
- {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."""
|
fin_infra/providers/base.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
|
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=
|
|
96
|
+
as_of = datetime.fromtimestamp(ts_raw, tz=UTC)
|
|
97
97
|
else:
|
|
98
|
-
as_of = datetime.now(
|
|
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=
|
|
153
|
+
dt = dt.replace(tzinfo=UTC)
|
|
154
154
|
|
|
155
155
|
ts_ms = int(dt.timestamp() * 1000)
|
|
156
156
|
|
fin_infra/providers/tax/irs.py
CHANGED
fin_infra/providers/tax/mock.py
CHANGED
|
@@ -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
|
-
|
|
24
|
-
CryptoTaxReport,
|
|
24
|
+
TaxFormW2,
|
|
25
25
|
TaxLiability,
|
|
26
26
|
)
|
|
27
27
|
from fin_infra.providers.base import TaxProvider
|
fin_infra/recurring/__init__.py
CHANGED
|
@@ -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
|
fin_infra/recurring/add.py
CHANGED
|
@@ -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
|
|
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:
|
|
39
|
+
llm_model: str | None = None,
|
|
39
40
|
include_in_schema: bool = True,
|
|
40
|
-
) ->
|
|
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:
|
|
246
|
+
category_map: dict[str, str] | None = None,
|
|
246
247
|
):
|
|
247
248
|
"""
|
|
248
249
|
Get comprehensive recurring transaction summary.
|
fin_infra/recurring/detector.py
CHANGED
|
@@ -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
|
|
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:
|
|
63
|
-
variable_detector_llm:
|
|
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:
|
|
516
|
-
variable_detector_llm:
|
|
517
|
-
insights_generator:
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
fin_infra/recurring/ease.py
CHANGED
|
@@ -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:
|
|
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}"
|
fin_infra/recurring/insights.py
CHANGED
|
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import Any,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
295
|
-
) ->
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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) ->
|
|
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
|
|
fin_infra/recurring/summary.py
CHANGED
|
@@ -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,
|
|
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:
|
|
255
|
+
category_map: dict[str, str] | None = None,
|
|
258
256
|
) -> RecurringSummary:
|
|
259
257
|
"""Generate a comprehensive recurring transaction summary for a user.
|
|
260
258
|
|
fin_infra/scaffold/budgets.py
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
35
|
-
schemas_filename:
|
|
36
|
-
repository_filename:
|
|
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:
|
|
232
|
+
repo_file: str | None,
|
|
233
233
|
) -> str:
|
|
234
234
|
"""Generate __init__.py content with re-exports.
|
|
235
235
|
|