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.
Files changed (83) hide show
  1. fin_infra/analytics/add.py +9 -11
  2. fin_infra/analytics/cash_flow.py +6 -5
  3. fin_infra/analytics/portfolio.py +13 -20
  4. fin_infra/analytics/rebalancing.py +2 -4
  5. fin_infra/analytics/savings.py +1 -1
  6. fin_infra/analytics/spending.py +15 -11
  7. fin_infra/banking/__init__.py +8 -5
  8. fin_infra/banking/history.py +3 -3
  9. fin_infra/banking/utils.py +93 -88
  10. fin_infra/brokerage/__init__.py +5 -3
  11. fin_infra/budgets/tracker.py +2 -3
  12. fin_infra/cashflows/__init__.py +6 -8
  13. fin_infra/categorization/__init__.py +1 -1
  14. fin_infra/categorization/add.py +15 -16
  15. fin_infra/categorization/ease.py +3 -4
  16. fin_infra/categorization/engine.py +4 -4
  17. fin_infra/categorization/llm_layer.py +5 -6
  18. fin_infra/categorization/models.py +1 -1
  19. fin_infra/chat/__init__.py +7 -16
  20. fin_infra/chat/planning.py +57 -0
  21. fin_infra/cli/cmds/scaffold_cmds.py +1 -1
  22. fin_infra/compliance/__init__.py +3 -3
  23. fin_infra/credit/add.py +3 -2
  24. fin_infra/credit/experian/auth.py +3 -2
  25. fin_infra/credit/experian/client.py +2 -2
  26. fin_infra/credit/experian/provider.py +16 -16
  27. fin_infra/crypto/__init__.py +1 -1
  28. fin_infra/crypto/insights.py +1 -3
  29. fin_infra/documents/add.py +5 -5
  30. fin_infra/documents/ease.py +4 -3
  31. fin_infra/documents/models.py +3 -3
  32. fin_infra/documents/ocr.py +1 -1
  33. fin_infra/documents/storage.py +2 -1
  34. fin_infra/exceptions.py +1 -1
  35. fin_infra/goals/add.py +2 -2
  36. fin_infra/goals/management.py +6 -6
  37. fin_infra/goals/milestones.py +2 -2
  38. fin_infra/insights/__init__.py +7 -8
  39. fin_infra/investments/__init__.py +13 -8
  40. fin_infra/investments/add.py +39 -59
  41. fin_infra/investments/ease.py +16 -13
  42. fin_infra/investments/models.py +130 -64
  43. fin_infra/investments/providers/base.py +3 -8
  44. fin_infra/investments/providers/plaid.py +23 -34
  45. fin_infra/investments/providers/snaptrade.py +22 -40
  46. fin_infra/markets/__init__.py +11 -8
  47. fin_infra/models/accounts.py +2 -1
  48. fin_infra/models/transactions.py +3 -2
  49. fin_infra/net_worth/add.py +8 -5
  50. fin_infra/net_worth/aggregator.py +5 -4
  51. fin_infra/net_worth/calculator.py +8 -6
  52. fin_infra/net_worth/ease.py +36 -15
  53. fin_infra/net_worth/insights.py +4 -4
  54. fin_infra/net_worth/models.py +237 -116
  55. fin_infra/normalization/__init__.py +15 -13
  56. fin_infra/normalization/providers/exchangerate.py +3 -3
  57. fin_infra/obs/classifier.py +2 -2
  58. fin_infra/providers/banking/plaid_client.py +20 -19
  59. fin_infra/providers/banking/teller_client.py +13 -7
  60. fin_infra/providers/base.py +105 -13
  61. fin_infra/providers/brokerage/alpaca.py +7 -7
  62. fin_infra/providers/credit/experian.py +5 -0
  63. fin_infra/providers/market/ccxt_crypto.py +8 -3
  64. fin_infra/providers/tax/mock.py +3 -3
  65. fin_infra/recurring/add.py +20 -9
  66. fin_infra/recurring/detector.py +1 -1
  67. fin_infra/recurring/detectors_llm.py +10 -9
  68. fin_infra/recurring/ease.py +1 -1
  69. fin_infra/recurring/insights.py +9 -8
  70. fin_infra/recurring/models.py +3 -3
  71. fin_infra/recurring/normalizer.py +3 -2
  72. fin_infra/recurring/normalizers.py +9 -8
  73. fin_infra/scaffold/__init__.py +1 -1
  74. fin_infra/security/encryption.py +2 -2
  75. fin_infra/security/pii_patterns.py +1 -1
  76. fin_infra/security/token_store.py +3 -1
  77. fin_infra/tax/__init__.py +1 -1
  78. fin_infra/utils/http.py +3 -2
  79. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
  80. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
  81. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
  82. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
  83. {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
@@ -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) -> Quote:
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) -> Sequence[Candle]:
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, symbol: str, qty: float, side: str, type_: str, time_in_force: str
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) -> dict | None:
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) -> list[dict]:
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 get_tax_document(self, document_id: str, **kwargs) -> dict:
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, transactions: list[dict], **kwargs) -> dict:
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 self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit)
30
+ return cast(
31
+ list[list[float]],
32
+ self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
33
+ )
@@ -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,
@@ -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
- # Store on app.state
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 = [] # Placeholder - in production: get_user_transactions(user_id)
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
- insights = await detector.insights_generator.generate(subscriptions)
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:
@@ -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": (45.0, 60.0),
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
- model=self.model_name,
283
- messages=[
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
 
@@ -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(
@@ -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
- model=self.model_name,
374
- messages=[
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
 
@@ -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
- ("Netflix", 15.99),
232
- ("Spotify", 9.99),
233
- ("Amazon Prime", 14.99),
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. " "Install with: pip install rapidfuzz"
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
- model=self.model_name,
345
- messages=[
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
 
@@ -12,7 +12,7 @@ Typical usage:
12
12
  include_tenant=True,
13
13
  include_soft_delete=True,
14
14
  )
15
-
15
+
16
16
  result = scaffold_goals_core(
17
17
  dest_dir=Path("app/models/goals"),
18
18
  include_tenant=False,
@@ -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(
@@ -67,7 +67,7 @@ def luhn_checksum(card_number: str) -> bool:
67
67
  True if valid, False otherwise
68
68
  """
69
69
 
70
- def digits_of(n):
70
+ def digits_of(n: int | str) -> list[int]:
71
71
  return [int(d) for d in str(n)]
72
72
 
73
73
  digits = digits_of(card_number)
@@ -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
- token = encryption.decrypt(token_obj.encrypted_token, context=context)
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}. " f"Supported providers: 'mock', 'irs', 'taxbit'"
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())