fin-infra 0.1.65__py3-none-any.whl → 0.1.67__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 (40) hide show
  1. fin_infra/analytics/cash_flow.py +6 -5
  2. fin_infra/analytics/portfolio.py +1 -2
  3. fin_infra/analytics/spending.py +6 -6
  4. fin_infra/banking/__init__.py +1 -1
  5. fin_infra/banking/utils.py +5 -6
  6. fin_infra/cashflows/__init__.py +6 -8
  7. fin_infra/categorization/engine.py +3 -3
  8. fin_infra/categorization/llm_layer.py +3 -4
  9. fin_infra/categorization/models.py +1 -1
  10. fin_infra/chat/__init__.py +6 -6
  11. fin_infra/chat/ease.py +1 -1
  12. fin_infra/compliance/__init__.py +0 -1
  13. fin_infra/crypto/insights.py +2 -4
  14. fin_infra/documents/add.py +1 -1
  15. fin_infra/insights/__init__.py +7 -7
  16. fin_infra/investments/__init__.py +1 -1
  17. fin_infra/investments/add.py +2 -3
  18. fin_infra/investments/models.py +100 -46
  19. fin_infra/investments/providers/plaid.py +2 -3
  20. fin_infra/investments/providers/snaptrade.py +2 -2
  21. fin_infra/markets/__init__.py +0 -4
  22. fin_infra/models/transactions.py +1 -1
  23. fin_infra/net_worth/add.py +8 -5
  24. fin_infra/net_worth/aggregator.py +5 -4
  25. fin_infra/net_worth/ease.py +1 -1
  26. fin_infra/net_worth/models.py +237 -116
  27. fin_infra/normalization/__init__.py +6 -6
  28. fin_infra/obs/classifier.py +2 -2
  29. fin_infra/providers/base.py +12 -17
  30. fin_infra/recurring/add.py +8 -8
  31. fin_infra/recurring/detectors_llm.py +9 -8
  32. fin_infra/recurring/ease.py +1 -1
  33. fin_infra/recurring/insights.py +8 -7
  34. fin_infra/recurring/models.py +3 -3
  35. fin_infra/recurring/normalizers.py +8 -7
  36. {fin_infra-0.1.65.dist-info → fin_infra-0.1.67.dist-info}/METADATA +1 -2
  37. {fin_infra-0.1.65.dist-info → fin_infra-0.1.67.dist-info}/RECORD +40 -40
  38. {fin_infra-0.1.65.dist-info → fin_infra-0.1.67.dist-info}/LICENSE +0 -0
  39. {fin_infra-0.1.65.dist-info → fin_infra-0.1.67.dist-info}/WHEEL +0 -0
  40. {fin_infra-0.1.65.dist-info → fin_infra-0.1.67.dist-info}/entry_points.txt +0 -0
@@ -36,13 +36,16 @@ tracker = add_net_worth_tracking(app)
36
36
  """
37
37
 
38
38
  from datetime import datetime, timedelta
39
+ from typing import Any
39
40
 
40
41
  from fastapi import FastAPI, HTTPException, Query
41
42
 
42
43
  from fin_infra.net_worth.ease import NetWorthTracker, easy_net_worth
43
44
  from fin_infra.net_worth.models import (
45
+ AssetDetail,
44
46
  ConversationResponse,
45
47
  GoalProgressResponse,
48
+ LiabilityDetail,
46
49
  NetWorthResponse,
47
50
  SnapshotHistoryResponse,
48
51
  )
@@ -188,8 +191,8 @@ def add_net_worth_tracking(
188
191
  # Persistence: Asset/liability details stored in snapshot JSON fields or separate tables.
189
192
  # Generate with: fin-infra scaffold net_worth --dest-dir app/models/
190
193
  # For now, create empty lists for testing/examples.
191
- asset_details = []
192
- liability_details = []
194
+ asset_details: list[AssetDetail] = []
195
+ liability_details: list[LiabilityDetail] = []
193
196
 
194
197
  # Calculate breakdowns
195
198
  asset_allocation = calculate_asset_allocation(asset_details)
@@ -508,7 +511,7 @@ def add_net_worth_tracking(
508
511
  snapshot = await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
509
512
 
510
513
  # Get goals for context (if goal_tracker available)
511
- goals = []
514
+ goals: list[Any] = []
512
515
  if tracker.goal_tracker:
513
516
  # TODO: Implement get_goals() method
514
517
  pass
@@ -657,10 +660,10 @@ def add_net_worth_tracking(
657
660
 
658
661
  try:
659
662
  # Get current snapshot
660
- snapshot = await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
663
+ await tracker.calculate_net_worth(user_id=user_id, access_token=access_token)
661
664
 
662
665
  # Get historical snapshots for progress tracking
663
- snapshots = await tracker.get_snapshots(user_id=user_id, days=90)
666
+ await tracker.get_snapshots(user_id=user_id, days=90)
664
667
 
665
668
  # Persistence: Goal retrieval via scaffolded goals repository.
666
669
  # Generate with: fin-infra scaffold goals --dest-dir app/models/
@@ -213,16 +213,17 @@ class NetWorthAggregator:
213
213
  results = await asyncio.gather(*tasks, return_exceptions=True)
214
214
 
215
215
  # Aggregate results (skip failed providers)
216
- all_assets = []
217
- all_liabilities = []
218
- actual_providers = []
216
+ all_assets: list[AssetDetail] = []
217
+ all_liabilities: list[LiabilityDetail] = []
218
+ actual_providers: list[str] = []
219
219
 
220
220
  for i, result in enumerate(results):
221
- if isinstance(result, Exception):
221
+ if isinstance(result, BaseException):
222
222
  # Log error but continue (graceful degradation)
223
223
  print(f"Provider {providers_used[i]} failed: {result}")
224
224
  continue
225
225
 
226
+ # result is now tuple[list[AssetDetail], list[LiabilityDetail]]
226
227
  assets, liabilities = result
227
228
  all_assets.extend(assets)
228
229
  all_liabilities.extend(liabilities)
@@ -377,7 +377,7 @@ def easy_net_worth(
377
377
 
378
378
  if enable_llm:
379
379
  try:
380
- from ai_infra.llm.llm import LLM # type: ignore[attr-defined]
380
+ from ai_infra.llm.llm import LLM
381
381
  except ImportError:
382
382
  raise ImportError(
383
383
  "LLM features require ai-infra package. " "Install with: pip install ai-infra"
@@ -19,6 +19,7 @@ Pydantic V2 models for net worth tracking.
19
19
 
20
20
  from datetime import datetime
21
21
  from enum import Enum
22
+ from typing import TYPE_CHECKING
22
23
 
23
24
  from pydantic import BaseModel, ConfigDict, Field, computed_field
24
25
 
@@ -207,54 +208,100 @@ class AssetAllocation(BaseModel):
207
208
  vehicles: float = Field(0.0, ge=0, description="Vehicle value")
208
209
  other_assets: float = Field(0.0, ge=0, description="Other asset value")
209
210
 
210
- @computed_field
211
- @property
212
- def total_assets(self) -> float:
213
- """Sum of all asset categories."""
214
- return (
215
- self.cash
216
- + self.investments
217
- + self.crypto
218
- + self.real_estate
219
- + self.vehicles
220
- + self.other_assets
221
- )
222
-
223
- @computed_field
224
- @property
225
- def cash_percentage(self) -> float:
226
- """Cash as percentage of total assets."""
227
- return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
228
-
229
- @computed_field
230
- @property
231
- def investments_percentage(self) -> float:
232
- """Investments as percentage of total assets."""
233
- return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
234
-
235
- @computed_field
236
- @property
237
- def crypto_percentage(self) -> float:
238
- """Crypto as percentage of total assets."""
239
- return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
240
-
241
- @computed_field
242
- @property
243
- def real_estate_percentage(self) -> float:
244
- """Real estate as percentage of total assets."""
245
- return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
246
-
247
- @computed_field
248
- @property
249
- def vehicles_percentage(self) -> float:
250
- """Vehicles as percentage of total assets."""
251
- return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
252
-
253
- @computed_field
254
- @property
255
- def other_percentage(self) -> float:
256
- """Other assets as percentage of total assets."""
257
- return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
211
+ if TYPE_CHECKING:
212
+
213
+ @property
214
+ def total_assets(self) -> float:
215
+ """Sum of all asset categories."""
216
+ return (
217
+ self.cash
218
+ + self.investments
219
+ + self.crypto
220
+ + self.real_estate
221
+ + self.vehicles
222
+ + self.other_assets
223
+ )
224
+
225
+ @property
226
+ def cash_percentage(self) -> float:
227
+ """Cash as percentage of total assets."""
228
+ return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
229
+
230
+ @property
231
+ def investments_percentage(self) -> float:
232
+ """Investments as percentage of total assets."""
233
+ return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
234
+
235
+ @property
236
+ def crypto_percentage(self) -> float:
237
+ """Crypto as percentage of total assets."""
238
+ return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
239
+
240
+ @property
241
+ def real_estate_percentage(self) -> float:
242
+ """Real estate as percentage of total assets."""
243
+ return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
244
+
245
+ @property
246
+ def vehicles_percentage(self) -> float:
247
+ """Vehicles as percentage of total assets."""
248
+ return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
249
+
250
+ @property
251
+ def other_percentage(self) -> float:
252
+ """Other assets as percentage of total assets."""
253
+ return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
254
+
255
+ else:
256
+
257
+ @computed_field
258
+ @property
259
+ def total_assets(self) -> float:
260
+ """Sum of all asset categories."""
261
+ return (
262
+ self.cash
263
+ + self.investments
264
+ + self.crypto
265
+ + self.real_estate
266
+ + self.vehicles
267
+ + self.other_assets
268
+ )
269
+
270
+ @computed_field
271
+ @property
272
+ def cash_percentage(self) -> float:
273
+ """Cash as percentage of total assets."""
274
+ return (self.cash / self.total_assets * 100) if self.total_assets > 0 else 0.0
275
+
276
+ @computed_field
277
+ @property
278
+ def investments_percentage(self) -> float:
279
+ """Investments as percentage of total assets."""
280
+ return (self.investments / self.total_assets * 100) if self.total_assets > 0 else 0.0
281
+
282
+ @computed_field
283
+ @property
284
+ def crypto_percentage(self) -> float:
285
+ """Crypto as percentage of total assets."""
286
+ return (self.crypto / self.total_assets * 100) if self.total_assets > 0 else 0.0
287
+
288
+ @computed_field
289
+ @property
290
+ def real_estate_percentage(self) -> float:
291
+ """Real estate as percentage of total assets."""
292
+ return (self.real_estate / self.total_assets * 100) if self.total_assets > 0 else 0.0
293
+
294
+ @computed_field
295
+ @property
296
+ def vehicles_percentage(self) -> float:
297
+ """Vehicles as percentage of total assets."""
298
+ return (self.vehicles / self.total_assets * 100) if self.total_assets > 0 else 0.0
299
+
300
+ @computed_field
301
+ @property
302
+ def other_percentage(self) -> float:
303
+ """Other assets as percentage of total assets."""
304
+ return (self.other_assets / self.total_assets * 100) if self.total_assets > 0 else 0.0
258
305
 
259
306
 
260
307
  class LiabilityBreakdown(BaseModel):
@@ -288,74 +335,148 @@ class LiabilityBreakdown(BaseModel):
288
335
  personal_loans: float = Field(0.0, ge=0, description="Personal loan balance")
289
336
  lines_of_credit: float = Field(0.0, ge=0, description="Line of credit balance")
290
337
 
291
- @computed_field
292
- @property
293
- def total_liabilities(self) -> float:
294
- """Sum of all liability categories."""
295
- return (
296
- self.credit_cards
297
- + self.mortgages
298
- + self.auto_loans
299
- + self.student_loans
300
- + self.personal_loans
301
- + self.lines_of_credit
302
- )
303
-
304
- @computed_field
305
- @property
306
- def credit_cards_percentage(self) -> float:
307
- """Credit cards as percentage of total liabilities."""
308
- return (
309
- (self.credit_cards / self.total_liabilities * 100)
310
- if self.total_liabilities > 0
311
- else 0.0
312
- )
313
-
314
- @computed_field
315
- @property
316
- def mortgages_percentage(self) -> float:
317
- """Mortgages as percentage of total liabilities."""
318
- return (
319
- (self.mortgages / self.total_liabilities * 100) if self.total_liabilities > 0 else 0.0
320
- )
321
-
322
- @computed_field
323
- @property
324
- def auto_loans_percentage(self) -> float:
325
- """Auto loans as percentage of total liabilities."""
326
- return (
327
- (self.auto_loans / self.total_liabilities * 100) if self.total_liabilities > 0 else 0.0
328
- )
329
-
330
- @computed_field
331
- @property
332
- def student_loans_percentage(self) -> float:
333
- """Student loans as percentage of total liabilities."""
334
- return (
335
- (self.student_loans / self.total_liabilities * 100)
336
- if self.total_liabilities > 0
337
- else 0.0
338
- )
339
-
340
- @computed_field
341
- @property
342
- def personal_loans_percentage(self) -> float:
343
- """Personal loans as percentage of total liabilities."""
344
- return (
345
- (self.personal_loans / self.total_liabilities * 100)
346
- if self.total_liabilities > 0
347
- else 0.0
348
- )
349
-
350
- @computed_field
351
- @property
352
- def lines_of_credit_percentage(self) -> float:
353
- """Lines of credit as percentage of total liabilities."""
354
- return (
355
- (self.lines_of_credit / self.total_liabilities * 100)
356
- if self.total_liabilities > 0
357
- else 0.0
358
- )
338
+ if TYPE_CHECKING:
339
+
340
+ @property
341
+ def total_liabilities(self) -> float:
342
+ """Sum of all liability categories."""
343
+ return (
344
+ self.credit_cards
345
+ + self.mortgages
346
+ + self.auto_loans
347
+ + self.student_loans
348
+ + self.personal_loans
349
+ + self.lines_of_credit
350
+ )
351
+
352
+ @property
353
+ def credit_cards_percentage(self) -> float:
354
+ """Credit cards as percentage of total liabilities."""
355
+ return (
356
+ (self.credit_cards / self.total_liabilities * 100)
357
+ if self.total_liabilities > 0
358
+ else 0.0
359
+ )
360
+
361
+ @property
362
+ def mortgages_percentage(self) -> float:
363
+ """Mortgages as percentage of total liabilities."""
364
+ return (
365
+ (self.mortgages / self.total_liabilities * 100)
366
+ if self.total_liabilities > 0
367
+ else 0.0
368
+ )
369
+
370
+ @property
371
+ def auto_loans_percentage(self) -> float:
372
+ """Auto loans as percentage of total liabilities."""
373
+ return (
374
+ (self.auto_loans / self.total_liabilities * 100)
375
+ if self.total_liabilities > 0
376
+ else 0.0
377
+ )
378
+
379
+ @property
380
+ def student_loans_percentage(self) -> float:
381
+ """Student loans as percentage of total liabilities."""
382
+ return (
383
+ (self.student_loans / self.total_liabilities * 100)
384
+ if self.total_liabilities > 0
385
+ else 0.0
386
+ )
387
+
388
+ @property
389
+ def personal_loans_percentage(self) -> float:
390
+ """Personal loans as percentage of total liabilities."""
391
+ return (
392
+ (self.personal_loans / self.total_liabilities * 100)
393
+ if self.total_liabilities > 0
394
+ else 0.0
395
+ )
396
+
397
+ @property
398
+ def lines_of_credit_percentage(self) -> float:
399
+ """Lines of credit as percentage of total liabilities."""
400
+ return (
401
+ (self.lines_of_credit / self.total_liabilities * 100)
402
+ if self.total_liabilities > 0
403
+ else 0.0
404
+ )
405
+
406
+ else:
407
+
408
+ @computed_field
409
+ @property
410
+ def total_liabilities(self) -> float:
411
+ """Sum of all liability categories."""
412
+ return (
413
+ self.credit_cards
414
+ + self.mortgages
415
+ + self.auto_loans
416
+ + self.student_loans
417
+ + self.personal_loans
418
+ + self.lines_of_credit
419
+ )
420
+
421
+ @computed_field
422
+ @property
423
+ def credit_cards_percentage(self) -> float:
424
+ """Credit cards as percentage of total liabilities."""
425
+ return (
426
+ (self.credit_cards / self.total_liabilities * 100)
427
+ if self.total_liabilities > 0
428
+ else 0.0
429
+ )
430
+
431
+ @computed_field
432
+ @property
433
+ def mortgages_percentage(self) -> float:
434
+ """Mortgages as percentage of total liabilities."""
435
+ return (
436
+ (self.mortgages / self.total_liabilities * 100)
437
+ if self.total_liabilities > 0
438
+ else 0.0
439
+ )
440
+
441
+ @computed_field
442
+ @property
443
+ def auto_loans_percentage(self) -> float:
444
+ """Auto loans as percentage of total liabilities."""
445
+ return (
446
+ (self.auto_loans / self.total_liabilities * 100)
447
+ if self.total_liabilities > 0
448
+ else 0.0
449
+ )
450
+
451
+ @computed_field
452
+ @property
453
+ def student_loans_percentage(self) -> float:
454
+ """Student loans as percentage of total liabilities."""
455
+ return (
456
+ (self.student_loans / self.total_liabilities * 100)
457
+ if self.total_liabilities > 0
458
+ else 0.0
459
+ )
460
+
461
+ @computed_field
462
+ @property
463
+ def personal_loans_percentage(self) -> float:
464
+ """Personal loans as percentage of total liabilities."""
465
+ return (
466
+ (self.personal_loans / self.total_liabilities * 100)
467
+ if self.total_liabilities > 0
468
+ else 0.0
469
+ )
470
+
471
+ @computed_field
472
+ @property
473
+ def lines_of_credit_percentage(self) -> float:
474
+ """Lines of credit as percentage of total liabilities."""
475
+ return (
476
+ (self.lines_of_credit / self.total_liabilities * 100)
477
+ if self.total_liabilities > 0
478
+ else 0.0
479
+ )
359
480
 
360
481
 
361
482
  class AssetDetail(BaseModel):
@@ -1,5 +1,10 @@
1
1
  """Data normalization module for financial symbols and currencies."""
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from fastapi import FastAPI
7
+
3
8
  from fin_infra.normalization.currency_converter import (
4
9
  CurrencyConverter,
5
10
  CurrencyNotSupportedError,
@@ -64,7 +69,7 @@ def easy_normalization(
64
69
 
65
70
 
66
71
  def add_normalization(
67
- app: "FastAPI", # type: ignore
72
+ app: "FastAPI",
68
73
  *,
69
74
  prefix: str = "/normalize",
70
75
  api_key: str | None = None,
@@ -110,11 +115,6 @@ def add_normalization(
110
115
  - Integrated with svc-infra observability (request metrics)
111
116
  - Scoped docs at {prefix}/docs for standalone documentation
112
117
  """
113
- from typing import TYPE_CHECKING
114
-
115
- if TYPE_CHECKING:
116
- from fastapi import FastAPI
117
-
118
118
  # Import FastAPI dependencies
119
119
  from fastapi import Query, HTTPException
120
120
 
@@ -112,9 +112,9 @@ def financial_route_classifier(route_path: str, method: str) -> str:
112
112
 
113
113
 
114
114
  def compose_classifiers(
115
- *classifiers: Callable[[str], str],
115
+ *classifiers: Callable[[str, str], str],
116
116
  default: str = "public",
117
- ) -> Callable[[str], str]:
117
+ ) -> Callable[[str, str], str]:
118
118
  """
119
119
  Compose multiple route classifiers with fallback logic.
120
120
 
@@ -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
 
@@ -161,11 +161,11 @@ class IdentityProvider(ABC):
161
161
 
162
162
  class CreditProvider(ABC):
163
163
  @abstractmethod
164
- def get_credit_score(self, user_id: str, **kwargs) -> dict | None:
164
+ def get_credit_score(self, user_id: str, **kwargs: Any) -> Any:
165
165
  pass
166
166
 
167
167
  @abstractmethod
168
- def get_credit_report(self, user_id: str, **kwargs) -> dict | None:
168
+ def get_credit_report(self, user_id: str, **kwargs: Any) -> Any:
169
169
  """Retrieve full credit report for a user."""
170
170
  pass
171
171
 
@@ -174,36 +174,31 @@ class TaxProvider(ABC):
174
174
  """Provider for tax data and document retrieval."""
175
175
 
176
176
  @abstractmethod
177
- 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:
178
178
  """Retrieve tax forms for a user and tax year."""
179
179
  pass
180
180
 
181
181
  @abstractmethod
182
- def get_tax_documents(self, user_id: str, tax_year: int, **kwargs) -> list[dict]:
182
+ def get_tax_documents(self, user_id: str, tax_year: int, **kwargs: Any) -> Any:
183
183
  """Retrieve tax documents for a user and tax year."""
184
184
  pass
185
185
 
186
186
  @abstractmethod
187
- def get_tax_document(self, document_id: str, **kwargs) -> dict:
187
+ def get_tax_document(self, document_id: str, **kwargs: Any) -> Any:
188
188
  """Retrieve a specific tax document by ID."""
189
189
  pass
190
190
 
191
191
  @abstractmethod
192
- def calculate_crypto_gains(self, transactions: list[dict], **kwargs) -> dict:
192
+ def calculate_crypto_gains(self, *args: Any, **kwargs: Any) -> Any:
193
193
  """Calculate capital gains from crypto transactions."""
194
194
  pass
195
195
 
196
196
  @abstractmethod
197
197
  def calculate_tax_liability(
198
198
  self,
199
- user_id: str,
200
- income: float,
201
- deductions: float,
202
- filing_status: str,
203
- tax_year: int,
204
- state: str | None = None,
205
- **kwargs,
206
- ) -> dict:
199
+ *args: Any,
200
+ **kwargs: Any,
201
+ ) -> Any:
207
202
  """Calculate estimated tax liability."""
208
203
  pass
209
204
 
@@ -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,7 @@ 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[dict[str, Any]] = [] # Placeholder - in production: get_user_transactions(user_id)
325
325
  patterns = detector.detect_patterns(transactions)
326
326
 
327
327
  # Generate summary
@@ -375,7 +375,7 @@ def add_recurring_detection(
375
375
  **Cost:** ~$0.0002/generation with Google Gemini, <$0.00004 effective with caching
376
376
  """
377
377
  # Get detected patterns
378
- transactions = [] # Placeholder
378
+ transactions: list[dict[str, Any]] = [] # Placeholder
379
379
  patterns = detector.detect_patterns(transactions)
380
380
 
381
381
  # Convert patterns to subscription dicts for LLM