fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
fin_infra/providers/base.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import Iterable, Sequence
|
|
4
|
+
from typing import Any, Iterable, Sequence
|
|
5
5
|
|
|
6
6
|
from ..models import Quote, Candle
|
|
7
7
|
|
|
@@ -20,11 +20,11 @@ class MarketDataProvider(ABC):
|
|
|
20
20
|
|
|
21
21
|
class CryptoDataProvider(ABC):
|
|
22
22
|
@abstractmethod
|
|
23
|
-
def ticker(self, symbol_pair: str) ->
|
|
23
|
+
def ticker(self, symbol_pair: str) -> Any:
|
|
24
24
|
pass
|
|
25
25
|
|
|
26
26
|
@abstractmethod
|
|
27
|
-
def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) ->
|
|
27
|
+
def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> Any:
|
|
28
28
|
pass
|
|
29
29
|
|
|
30
30
|
|
|
@@ -67,7 +67,15 @@ class BankingProvider(ABC):
|
|
|
67
67
|
class BrokerageProvider(ABC):
|
|
68
68
|
@abstractmethod
|
|
69
69
|
def submit_order(
|
|
70
|
-
self,
|
|
70
|
+
self,
|
|
71
|
+
symbol: str,
|
|
72
|
+
qty: float,
|
|
73
|
+
side: str,
|
|
74
|
+
type_: str,
|
|
75
|
+
time_in_force: str,
|
|
76
|
+
limit_price: float | None = None,
|
|
77
|
+
stop_price: float | None = None,
|
|
78
|
+
client_order_id: str | None = None,
|
|
71
79
|
) -> dict:
|
|
72
80
|
pass
|
|
73
81
|
|
|
@@ -75,6 +83,71 @@ class BrokerageProvider(ABC):
|
|
|
75
83
|
def positions(self) -> Iterable[dict]:
|
|
76
84
|
pass
|
|
77
85
|
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def get_account(self) -> dict:
|
|
88
|
+
"""Get trading account information."""
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def get_position(self, symbol: str) -> dict:
|
|
93
|
+
"""Get position for a specific symbol."""
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def close_position(self, symbol: str) -> dict:
|
|
98
|
+
"""Close a position (market sell/cover)."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def list_orders(self, status: str = "open", limit: int = 50) -> list[dict]:
|
|
103
|
+
"""List orders."""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def get_order(self, order_id: str) -> dict:
|
|
108
|
+
"""Get order by ID."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
def cancel_order(self, order_id: str) -> None:
|
|
113
|
+
"""Cancel an order."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def get_portfolio_history(self, period: str = "1M", timeframe: str = "1D") -> dict:
|
|
118
|
+
"""Get portfolio value history."""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def create_watchlist(self, name: str, symbols: list[str] | None = None) -> dict:
|
|
123
|
+
"""Create a new watchlist."""
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def list_watchlists(self) -> list[dict]:
|
|
128
|
+
"""List all watchlists."""
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def get_watchlist(self, watchlist_id: str) -> dict:
|
|
133
|
+
"""Get a watchlist by ID."""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
def delete_watchlist(self, watchlist_id: str) -> None:
|
|
138
|
+
"""Delete a watchlist."""
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def add_to_watchlist(self, watchlist_id: str, symbol: str) -> dict:
|
|
143
|
+
"""Add a symbol to a watchlist."""
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
def remove_from_watchlist(self, watchlist_id: str, symbol: str) -> dict:
|
|
148
|
+
"""Remove a symbol from a watchlist."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
78
151
|
|
|
79
152
|
class IdentityProvider(ABC):
|
|
80
153
|
@abstractmethod
|
|
@@ -88,7 +161,12 @@ class IdentityProvider(ABC):
|
|
|
88
161
|
|
|
89
162
|
class CreditProvider(ABC):
|
|
90
163
|
@abstractmethod
|
|
91
|
-
def get_credit_score(self, user_id: str, **kwargs) ->
|
|
164
|
+
def get_credit_score(self, user_id: str, **kwargs: Any) -> Any:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def get_credit_report(self, user_id: str, **kwargs: Any) -> Any:
|
|
169
|
+
"""Retrieve full credit report for a user."""
|
|
92
170
|
pass
|
|
93
171
|
|
|
94
172
|
|
|
@@ -96,44 +174,58 @@ class TaxProvider(ABC):
|
|
|
96
174
|
"""Provider for tax data and document retrieval."""
|
|
97
175
|
|
|
98
176
|
@abstractmethod
|
|
99
|
-
def get_tax_forms(self, user_id: str, tax_year: int, **kwargs) ->
|
|
177
|
+
def get_tax_forms(self, user_id: str, tax_year: int, **kwargs: Any) -> Any:
|
|
100
178
|
"""Retrieve tax forms for a user and tax year."""
|
|
101
179
|
pass
|
|
102
180
|
|
|
103
181
|
@abstractmethod
|
|
104
|
-
def
|
|
182
|
+
def get_tax_documents(self, user_id: str, tax_year: int, **kwargs: Any) -> Any:
|
|
183
|
+
"""Retrieve tax documents for a user and tax year."""
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
def get_tax_document(self, document_id: str, **kwargs: Any) -> Any:
|
|
105
188
|
"""Retrieve a specific tax document by ID."""
|
|
106
189
|
pass
|
|
107
190
|
|
|
108
191
|
@abstractmethod
|
|
109
|
-
def calculate_crypto_gains(self,
|
|
192
|
+
def calculate_crypto_gains(self, *args: Any, **kwargs: Any) -> Any:
|
|
110
193
|
"""Calculate capital gains from crypto transactions."""
|
|
111
194
|
pass
|
|
112
195
|
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def calculate_tax_liability(
|
|
198
|
+
self,
|
|
199
|
+
*args: Any,
|
|
200
|
+
**kwargs: Any,
|
|
201
|
+
) -> Any:
|
|
202
|
+
"""Calculate estimated tax liability."""
|
|
203
|
+
pass
|
|
204
|
+
|
|
113
205
|
|
|
114
206
|
class InvestmentProvider(ABC):
|
|
115
207
|
"""Provider for investment holdings and portfolio data (Plaid, SnapTrade).
|
|
116
|
-
|
|
208
|
+
|
|
117
209
|
This is a minimal ABC for type checking. The full implementation with
|
|
118
210
|
all abstract methods is in fin_infra.investments.providers.base.InvestmentProvider.
|
|
119
|
-
|
|
211
|
+
|
|
120
212
|
Abstract Methods (defined in full implementation):
|
|
121
213
|
- get_holdings(access_token, account_ids) -> List[Holding]
|
|
122
214
|
- get_transactions(access_token, start_date, end_date, account_ids) -> List[InvestmentTransaction]
|
|
123
215
|
- get_securities(access_token, security_ids) -> List[Security]
|
|
124
216
|
- get_investment_accounts(access_token) -> List[InvestmentAccount]
|
|
125
|
-
|
|
217
|
+
|
|
126
218
|
Example:
|
|
127
219
|
>>> from fin_infra.investments import easy_investments
|
|
128
220
|
>>> provider = easy_investments(provider="plaid")
|
|
129
221
|
>>> holdings = await provider.get_holdings(access_token)
|
|
130
222
|
"""
|
|
131
|
-
|
|
223
|
+
|
|
132
224
|
@abstractmethod
|
|
133
225
|
async def get_holdings(self, access_token: str, account_ids: list[str] | None = None) -> list:
|
|
134
226
|
"""Fetch holdings for investment accounts."""
|
|
135
227
|
pass
|
|
136
|
-
|
|
228
|
+
|
|
137
229
|
@abstractmethod
|
|
138
230
|
async def get_investment_accounts(self, access_token: str) -> list:
|
|
139
231
|
"""Fetch investment accounts with aggregated holdings."""
|
|
@@ -7,7 +7,7 @@ mode for development and testing. Live trading requires explicit opt-in.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
-
from typing import Literal
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
11
|
|
|
12
12
|
try:
|
|
13
13
|
from alpaca_trade_api import REST
|
|
@@ -55,8 +55,7 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
55
55
|
) -> None:
|
|
56
56
|
if not ALPACA_AVAILABLE:
|
|
57
57
|
raise ImportError(
|
|
58
|
-
"alpaca-trade-api is not installed. "
|
|
59
|
-
"Install it with: pip install alpaca-trade-api"
|
|
58
|
+
"alpaca-trade-api is not installed. Install it with: pip install alpaca-trade-api"
|
|
60
59
|
)
|
|
61
60
|
|
|
62
61
|
# Get credentials from args or environment
|
|
@@ -128,6 +127,7 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
128
127
|
# Without this, network retries can cause duplicate order execution = MONEY LOSS.
|
|
129
128
|
if client_order_id is None:
|
|
130
129
|
import uuid
|
|
130
|
+
|
|
131
131
|
client_order_id = str(uuid.uuid4())
|
|
132
132
|
|
|
133
133
|
order = self.client.submit_order(
|
|
@@ -308,14 +308,14 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
308
308
|
return self._extract_raw(watchlist)
|
|
309
309
|
|
|
310
310
|
@staticmethod
|
|
311
|
-
def _extract_raw(obj) -> dict:
|
|
311
|
+
def _extract_raw(obj: Any) -> dict[Any, Any]:
|
|
312
312
|
"""Extract raw dict from Alpaca entity object.
|
|
313
313
|
|
|
314
314
|
Alpaca entities have a _raw attribute with the API response data.
|
|
315
315
|
"""
|
|
316
316
|
if hasattr(obj, "_raw"):
|
|
317
|
-
return obj._raw
|
|
317
|
+
return cast(dict[Any, Any], obj._raw)
|
|
318
318
|
elif hasattr(obj, "__dict__"):
|
|
319
|
-
return obj.__dict__
|
|
319
|
+
return cast(dict[Any, Any], obj.__dict__)
|
|
320
320
|
else:
|
|
321
|
-
return obj
|
|
321
|
+
return cast(dict[Any, Any], obj)
|
|
@@ -11,3 +11,8 @@ class ExperianCredit(CreditProvider):
|
|
|
11
11
|
self, user_id: str, **kwargs
|
|
12
12
|
) -> dict | None: # pragma: no cover - placeholder
|
|
13
13
|
return None
|
|
14
|
+
|
|
15
|
+
def get_credit_report(
|
|
16
|
+
self, user_id: str, **kwargs
|
|
17
|
+
) -> dict | None: # pragma: no cover - placeholder
|
|
18
|
+
return None
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
3
5
|
import ccxt
|
|
4
6
|
|
|
5
7
|
from ..base import CryptoDataProvider
|
|
@@ -15,14 +17,17 @@ class CCXTCryptoData(CryptoDataProvider):
|
|
|
15
17
|
# Defer load_markets to first call to avoid network on construction
|
|
16
18
|
self._markets_loaded = False
|
|
17
19
|
|
|
18
|
-
def ticker(self, symbol_pair: str) -> dict:
|
|
20
|
+
def ticker(self, symbol_pair: str) -> dict[Any, Any]:
|
|
19
21
|
if not self._markets_loaded:
|
|
20
22
|
self.exchange.load_markets()
|
|
21
23
|
self._markets_loaded = True
|
|
22
|
-
return self.exchange.fetch_ticker(symbol_pair)
|
|
24
|
+
return cast(dict[Any, Any], self.exchange.fetch_ticker(symbol_pair))
|
|
23
25
|
|
|
24
26
|
def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[list[float]]:
|
|
25
27
|
if not self._markets_loaded:
|
|
26
28
|
self.exchange.load_markets()
|
|
27
29
|
self._markets_loaded = True
|
|
28
|
-
return
|
|
30
|
+
return cast(
|
|
31
|
+
list[list[float]],
|
|
32
|
+
self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
|
|
33
|
+
)
|
fin_infra/providers/tax/mock.py
CHANGED
|
@@ -313,9 +313,9 @@ class MockTaxProvider(TaxProvider):
|
|
|
313
313
|
return CryptoTaxReport(
|
|
314
314
|
user_id=user_id,
|
|
315
315
|
tax_year=tax_year,
|
|
316
|
-
total_gain_loss=short_term + long_term,
|
|
317
|
-
short_term_gain_loss=short_term,
|
|
318
|
-
long_term_gain_loss=long_term,
|
|
316
|
+
total_gain_loss=Decimal(short_term + long_term),
|
|
317
|
+
short_term_gain_loss=Decimal(short_term),
|
|
318
|
+
long_term_gain_loss=Decimal(long_term),
|
|
319
319
|
transaction_count=len(crypto_transactions),
|
|
320
320
|
cost_basis_method=cost_basis_method,
|
|
321
321
|
transactions=crypto_transactions,
|
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, Optional
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
15
15
|
|
|
16
16
|
from .ease import easy_recurring_detection
|
|
17
17
|
from .models import (
|
|
@@ -93,7 +93,7 @@ def add_recurring_detection(
|
|
|
93
93
|
llm_model=llm_model,
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# Store on app.state
|
|
97
97
|
app.state.recurring_detector = detector
|
|
98
98
|
|
|
99
99
|
# Use svc-infra user_router for authentication (recurring detection is user-specific)
|
|
@@ -133,7 +133,7 @@ def add_recurring_detection(
|
|
|
133
133
|
# For now, return empty result with structure.
|
|
134
134
|
# In production: transactions = get_user_transactions(user.id, days=request.days)
|
|
135
135
|
|
|
136
|
-
transactions = [] # Placeholder
|
|
136
|
+
transactions: list[dict[str, Any]] = [] # Placeholder
|
|
137
137
|
|
|
138
138
|
# Detect patterns
|
|
139
139
|
patterns = detector.detect_patterns(transactions)
|
|
@@ -180,7 +180,7 @@ def add_recurring_detection(
|
|
|
180
180
|
# return cached
|
|
181
181
|
|
|
182
182
|
# Detect patterns (same as /detect endpoint)
|
|
183
|
-
transactions = [] # Placeholder
|
|
183
|
+
transactions: list[dict[str, Any]] = [] # Placeholder
|
|
184
184
|
patterns = detector.detect_patterns(transactions)
|
|
185
185
|
patterns = [p for p in patterns if p.confidence >= min_confidence]
|
|
186
186
|
|
|
@@ -208,7 +208,7 @@ def add_recurring_detection(
|
|
|
208
208
|
List of predicted charges with expected dates and amounts
|
|
209
209
|
"""
|
|
210
210
|
# Get detected patterns
|
|
211
|
-
transactions = [] # Placeholder
|
|
211
|
+
transactions: list[dict[str, Any]] = [] # Placeholder
|
|
212
212
|
patterns = detector.detect_patterns(transactions)
|
|
213
213
|
patterns = [p for p in patterns if p.confidence >= min_confidence]
|
|
214
214
|
|
|
@@ -230,7 +230,7 @@ def add_recurring_detection(
|
|
|
230
230
|
- Top merchants by amount
|
|
231
231
|
"""
|
|
232
232
|
# Get all detected patterns
|
|
233
|
-
transactions = [] # Placeholder
|
|
233
|
+
transactions: list[dict[str, Any]] = [] # Placeholder
|
|
234
234
|
patterns = detector.detect_patterns(transactions)
|
|
235
235
|
|
|
236
236
|
# Calculate stats
|
|
@@ -321,7 +321,9 @@ def add_recurring_detection(
|
|
|
321
321
|
from .summary import get_recurring_summary
|
|
322
322
|
|
|
323
323
|
# Get detected patterns for user
|
|
324
|
-
transactions
|
|
324
|
+
transactions: list[
|
|
325
|
+
dict[str, Any]
|
|
326
|
+
] = [] # Placeholder - in production: get_user_transactions(user_id)
|
|
325
327
|
patterns = detector.detect_patterns(transactions)
|
|
326
328
|
|
|
327
329
|
# Generate summary
|
|
@@ -375,7 +377,7 @@ def add_recurring_detection(
|
|
|
375
377
|
**Cost:** ~$0.0002/generation with Google Gemini, <$0.00004 effective with caching
|
|
376
378
|
"""
|
|
377
379
|
# Get detected patterns
|
|
378
|
-
transactions = [] # Placeholder
|
|
380
|
+
transactions: list[dict[str, Any]] = [] # Placeholder
|
|
379
381
|
patterns = detector.detect_patterns(transactions)
|
|
380
382
|
|
|
381
383
|
# Convert patterns to subscription dicts for LLM
|
|
@@ -403,7 +405,16 @@ def add_recurring_detection(
|
|
|
403
405
|
|
|
404
406
|
# Generate insights with LLM
|
|
405
407
|
# TODO: Pass user_id for better caching (currently uses subscriptions hash)
|
|
406
|
-
|
|
408
|
+
insights_generator = detector.insights_generator
|
|
409
|
+
if insights_generator is None:
|
|
410
|
+
from fastapi import HTTPException
|
|
411
|
+
|
|
412
|
+
raise HTTPException(
|
|
413
|
+
status_code=500,
|
|
414
|
+
detail="Subscription insights generator not configured (enable_llm=True required).",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
insights = await insights_generator.generate(subscriptions)
|
|
407
418
|
|
|
408
419
|
return insights
|
|
409
420
|
else:
|
fin_infra/recurring/detector.py
CHANGED
|
@@ -450,7 +450,7 @@ class PatternDetector:
|
|
|
450
450
|
min_amt, max_amt = pattern.amount_range or (0, 0)
|
|
451
451
|
return (
|
|
452
452
|
f"Variable amount ${min_amt:.2f}-${max_amt:.2f} charged {pattern.cadence.value} "
|
|
453
|
-
f"({pattern.amount_variance_pct*100:.1f}% variance, "
|
|
453
|
+
f"({pattern.amount_variance_pct * 100:.1f}% variance, "
|
|
454
454
|
f"{pattern.occurrence_count} occurrences)"
|
|
455
455
|
)
|
|
456
456
|
else: # IRREGULAR
|
|
@@ -14,15 +14,18 @@ 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
|
|
17
|
+
from typing import Any, Optional, cast
|
|
18
18
|
|
|
19
19
|
from pydantic import BaseModel, ConfigDict, Field
|
|
20
20
|
|
|
21
21
|
# Lazy import for optional dependency (ai-infra)
|
|
22
22
|
try:
|
|
23
23
|
from ai_infra.llm import LLM
|
|
24
|
+
|
|
25
|
+
LLM_AVAILABLE = True
|
|
24
26
|
except ImportError:
|
|
25
|
-
LLM = None
|
|
27
|
+
LLM = None # type: ignore[misc,assignment]
|
|
28
|
+
LLM_AVAILABLE = False
|
|
26
29
|
|
|
27
30
|
logger = logging.getLogger(__name__)
|
|
28
31
|
|
|
@@ -73,7 +76,7 @@ class VariableRecurringPattern(BaseModel):
|
|
|
73
76
|
"example": {
|
|
74
77
|
"is_recurring": True,
|
|
75
78
|
"cadence": "monthly",
|
|
76
|
-
"expected_range":
|
|
79
|
+
"expected_range": [45.0, 60.0],
|
|
77
80
|
"reasoning": "Seasonal winter heating causes variance",
|
|
78
81
|
"confidence": 0.85,
|
|
79
82
|
}
|
|
@@ -278,12 +281,10 @@ class VariableDetectorLLM:
|
|
|
278
281
|
)
|
|
279
282
|
|
|
280
283
|
response = await self.llm.achat(
|
|
284
|
+
user_msg=user_prompt,
|
|
281
285
|
provider=self.provider,
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{"role": "system", "content": VARIABLE_DETECTION_SYSTEM_PROMPT},
|
|
285
|
-
{"role": "user", "content": user_prompt},
|
|
286
|
-
],
|
|
286
|
+
model_name=self.model_name,
|
|
287
|
+
system=VARIABLE_DETECTION_SYSTEM_PROMPT,
|
|
287
288
|
output_schema=VariableRecurringPattern,
|
|
288
289
|
output_method="prompt", # Cross-provider compatibility
|
|
289
290
|
temperature=0.0, # Deterministic
|
|
@@ -292,7 +293,7 @@ class VariableDetectorLLM:
|
|
|
292
293
|
|
|
293
294
|
# Extract structured output
|
|
294
295
|
if hasattr(response, "structured") and response.structured:
|
|
295
|
-
return response.structured
|
|
296
|
+
return cast(VariableRecurringPattern, response.structured)
|
|
296
297
|
else:
|
|
297
298
|
raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
|
|
298
299
|
|
fin_infra/recurring/ease.py
CHANGED
|
@@ -200,7 +200,7 @@ def easy_recurring_detection(
|
|
|
200
200
|
)
|
|
201
201
|
|
|
202
202
|
# Validate config keys (reserved for future use)
|
|
203
|
-
valid_config_keys = set() # Will expand in future versions
|
|
203
|
+
valid_config_keys: set[str] = set() # Will expand in future versions
|
|
204
204
|
invalid_keys = set(config.keys()) - valid_config_keys
|
|
205
205
|
if invalid_keys:
|
|
206
206
|
raise ValueError(
|
fin_infra/recurring/insights.py
CHANGED
|
@@ -15,15 +15,18 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import Any, Optional
|
|
18
|
+
from typing import Any, Optional, cast
|
|
19
19
|
|
|
20
20
|
from pydantic import BaseModel, ConfigDict, Field
|
|
21
21
|
|
|
22
22
|
# Lazy import for optional dependency (ai-infra)
|
|
23
23
|
try:
|
|
24
24
|
from ai_infra.llm import LLM
|
|
25
|
+
|
|
26
|
+
LLM_AVAILABLE = True
|
|
25
27
|
except ImportError:
|
|
26
|
-
LLM = None
|
|
28
|
+
LLM = None # type: ignore[misc,assignment]
|
|
29
|
+
LLM_AVAILABLE = False
|
|
27
30
|
|
|
28
31
|
logger = logging.getLogger(__name__)
|
|
29
32
|
|
|
@@ -369,12 +372,10 @@ class SubscriptionInsightsGenerator:
|
|
|
369
372
|
user_prompt = INSIGHTS_GENERATION_USER_PROMPT.format(subscriptions_json=subscriptions_json)
|
|
370
373
|
|
|
371
374
|
response = await self.llm.achat(
|
|
375
|
+
user_msg=user_prompt,
|
|
372
376
|
provider=self.provider,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
{"role": "system", "content": INSIGHTS_GENERATION_SYSTEM_PROMPT},
|
|
376
|
-
{"role": "user", "content": user_prompt},
|
|
377
|
-
],
|
|
377
|
+
model_name=self.model_name,
|
|
378
|
+
system=INSIGHTS_GENERATION_SYSTEM_PROMPT,
|
|
378
379
|
output_schema=SubscriptionInsights,
|
|
379
380
|
output_method="prompt", # Cross-provider compatibility
|
|
380
381
|
temperature=0.3, # Slight creativity for recommendations
|
|
@@ -383,7 +384,7 @@ class SubscriptionInsightsGenerator:
|
|
|
383
384
|
|
|
384
385
|
# Extract structured output
|
|
385
386
|
if hasattr(response, "structured") and response.structured:
|
|
386
|
-
return response.structured
|
|
387
|
+
return cast(SubscriptionInsights, response.structured)
|
|
387
388
|
else:
|
|
388
389
|
raise ValueError("LLM returned no structured output for insights")
|
|
389
390
|
|
fin_infra/recurring/models.py
CHANGED
|
@@ -228,9 +228,9 @@ class SubscriptionStats(BaseModel):
|
|
|
228
228
|
"by_pattern_type": {"fixed": 12, "variable": 2, "irregular": 1},
|
|
229
229
|
"by_cadence": {"monthly": 13, "quarterly": 1, "annual": 1},
|
|
230
230
|
"top_merchants": [
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
["Netflix", 15.99],
|
|
232
|
+
["Spotify", 9.99],
|
|
233
|
+
["Amazon Prime", 14.99],
|
|
234
234
|
],
|
|
235
235
|
"confidence_distribution": {
|
|
236
236
|
"high (0.85-1.0)": 12,
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
13
|
from functools import lru_cache
|
|
14
|
+
from typing import cast
|
|
14
15
|
|
|
15
16
|
try:
|
|
16
17
|
from rapidfuzz import fuzz, process
|
|
@@ -95,7 +96,7 @@ class FuzzyMatcher:
|
|
|
95
96
|
"""
|
|
96
97
|
if not RAPIDFUZZ_AVAILABLE:
|
|
97
98
|
raise ImportError(
|
|
98
|
-
"rapidfuzz is required for fuzzy matching.
|
|
99
|
+
"rapidfuzz is required for fuzzy matching. Install with: pip install rapidfuzz"
|
|
99
100
|
)
|
|
100
101
|
self.similarity_threshold = similarity_threshold
|
|
101
102
|
|
|
@@ -165,7 +166,7 @@ class FuzzyMatcher:
|
|
|
165
166
|
norm2 = normalize_merchant(name2)
|
|
166
167
|
|
|
167
168
|
similarity = fuzz.token_sort_ratio(norm1, norm2)
|
|
168
|
-
return similarity >= self.similarity_threshold
|
|
169
|
+
return cast(bool, similarity >= self.similarity_threshold)
|
|
169
170
|
|
|
170
171
|
def group_merchants(self, merchants: list[str]) -> dict[str, list[str]]:
|
|
171
172
|
"""
|
|
@@ -16,15 +16,18 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import logging
|
|
19
|
-
from typing import Any, Optional
|
|
19
|
+
from typing import Any, Optional, cast
|
|
20
20
|
|
|
21
21
|
from pydantic import BaseModel, ConfigDict, Field
|
|
22
22
|
|
|
23
23
|
# Lazy import for optional dependency (ai-infra)
|
|
24
24
|
try:
|
|
25
25
|
from ai_infra.llm import LLM
|
|
26
|
+
|
|
27
|
+
LLM_AVAILABLE = True
|
|
26
28
|
except ImportError:
|
|
27
|
-
LLM = None
|
|
29
|
+
LLM = None # type: ignore[misc,assignment]
|
|
30
|
+
LLM_AVAILABLE = False
|
|
28
31
|
|
|
29
32
|
logger = logging.getLogger(__name__)
|
|
30
33
|
|
|
@@ -340,12 +343,10 @@ class MerchantNormalizer:
|
|
|
340
343
|
user_prompt = MERCHANT_NORMALIZATION_USER_PROMPT.format(merchant_name=merchant_name)
|
|
341
344
|
|
|
342
345
|
response = await self.llm.achat(
|
|
346
|
+
user_msg=user_prompt,
|
|
343
347
|
provider=self.provider,
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
{"role": "system", "content": MERCHANT_NORMALIZATION_SYSTEM_PROMPT},
|
|
347
|
-
{"role": "user", "content": user_prompt},
|
|
348
|
-
],
|
|
348
|
+
model_name=self.model_name,
|
|
349
|
+
system=MERCHANT_NORMALIZATION_SYSTEM_PROMPT,
|
|
349
350
|
output_schema=MerchantNormalized,
|
|
350
351
|
output_method="prompt", # Cross-provider compatibility
|
|
351
352
|
temperature=0.0, # Deterministic
|
|
@@ -354,7 +355,7 @@ class MerchantNormalizer:
|
|
|
354
355
|
|
|
355
356
|
# Extract structured output
|
|
356
357
|
if hasattr(response, "structured") and response.structured:
|
|
357
|
-
return response.structured
|
|
358
|
+
return cast(MerchantNormalized, response.structured)
|
|
358
359
|
else:
|
|
359
360
|
raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
|
|
360
361
|
|
fin_infra/scaffold/__init__.py
CHANGED
fin_infra/security/encryption.py
CHANGED
|
@@ -7,7 +7,7 @@ Encrypt/decrypt financial provider API tokens at rest.
|
|
|
7
7
|
import base64
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
-
from typing import Any, Dict, Optional
|
|
10
|
+
from typing import Any, Dict, Optional, cast
|
|
11
11
|
|
|
12
12
|
from cryptography.fernet import Fernet, InvalidToken
|
|
13
13
|
|
|
@@ -144,7 +144,7 @@ class ProviderTokenEncryption:
|
|
|
144
144
|
"Token may have been tampered with or used for wrong user/provider."
|
|
145
145
|
)
|
|
146
146
|
|
|
147
|
-
return data["token"]
|
|
147
|
+
return cast(str, data["token"])
|
|
148
148
|
|
|
149
149
|
except InvalidToken as e:
|
|
150
150
|
raise ValueError(
|
|
@@ -162,7 +162,9 @@ async def get_provider_token(
|
|
|
162
162
|
|
|
163
163
|
# Decrypt token
|
|
164
164
|
context = {"user_id": user_id, "provider": provider}
|
|
165
|
-
|
|
165
|
+
# Cast to str since SQLAlchemy Column[str] needs explicit conversion for type checker
|
|
166
|
+
encrypted_token_str: str = str(token_obj.encrypted_token)
|
|
167
|
+
token = encryption.decrypt(encrypted_token_str, context=context)
|
|
166
168
|
|
|
167
169
|
# Update last_used_at
|
|
168
170
|
update_stmt = (
|
fin_infra/tax/__init__.py
CHANGED
|
@@ -144,7 +144,7 @@ def easy_tax(provider: str | TaxProvider = "mock", **config) -> TaxProvider:
|
|
|
144
144
|
|
|
145
145
|
else:
|
|
146
146
|
raise ValueError(
|
|
147
|
-
f"Unknown tax provider: {provider}.
|
|
147
|
+
f"Unknown tax provider: {provider}. Supported providers: 'mock', 'irs', 'taxbit'"
|
|
148
148
|
)
|
|
149
149
|
|
|
150
150
|
|
fin_infra/utils/http.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import httpx
|
|
4
|
+
from typing import Any, cast
|
|
4
5
|
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
|
5
6
|
|
|
6
7
|
_DEFAULT_TIMEOUT = httpx.Timeout(20.0)
|
|
@@ -12,8 +13,8 @@ _DEFAULT_TIMEOUT = httpx.Timeout(20.0)
|
|
|
12
13
|
retry=retry_if_exception_type(httpx.HTTPError),
|
|
13
14
|
reraise=True,
|
|
14
15
|
)
|
|
15
|
-
async def aget_json(url: str, **kwargs) -> dict:
|
|
16
|
+
async def aget_json(url: str, **kwargs) -> dict[Any, Any]:
|
|
16
17
|
async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT) as client:
|
|
17
18
|
r = await client.get(url, **kwargs)
|
|
18
19
|
r.raise_for_status()
|
|
19
|
-
return r.json()
|
|
20
|
+
return cast(dict[Any, Any], r.json())
|