fin-infra 0.1.62__py3-none-any.whl → 0.1.82__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +30 -32
  4. fin_infra/analytics/cash_flow.py +6 -5
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/portfolio.py +19 -26
  7. fin_infra/analytics/projections.py +1 -3
  8. fin_infra/analytics/rebalancing.py +2 -4
  9. fin_infra/analytics/savings.py +1 -1
  10. fin_infra/analytics/spending.py +15 -11
  11. fin_infra/banking/__init__.py +33 -31
  12. fin_infra/banking/history.py +11 -12
  13. fin_infra/banking/utils.py +116 -110
  14. fin_infra/brokerage/__init__.py +27 -27
  15. fin_infra/budgets/__init__.py +3 -3
  16. fin_infra/budgets/add.py +16 -17
  17. fin_infra/budgets/alerts.py +3 -3
  18. fin_infra/budgets/tracker.py +4 -5
  19. fin_infra/cashflows/__init__.py +8 -10
  20. fin_infra/cashflows/core.py +1 -1
  21. fin_infra/categorization/__init__.py +1 -1
  22. fin_infra/categorization/add.py +17 -19
  23. fin_infra/categorization/ease.py +3 -4
  24. fin_infra/categorization/engine.py +21 -18
  25. fin_infra/categorization/llm_layer.py +10 -10
  26. fin_infra/categorization/models.py +1 -1
  27. fin_infra/categorization/rules.py +2 -4
  28. fin_infra/categorization/taxonomy.py +2 -2
  29. fin_infra/chat/__init__.py +13 -22
  30. fin_infra/chat/planning.py +57 -1
  31. fin_infra/cli/cmds/scaffold_cmds.py +11 -12
  32. fin_infra/clients/__init__.py +23 -1
  33. fin_infra/clients/base.py +1 -1
  34. fin_infra/clients/plaid.py +2 -2
  35. fin_infra/compliance/__init__.py +7 -6
  36. fin_infra/credit/add.py +7 -7
  37. fin_infra/credit/experian/auth.py +3 -2
  38. fin_infra/credit/experian/client.py +2 -2
  39. fin_infra/credit/experian/provider.py +19 -19
  40. fin_infra/crypto/__init__.py +8 -10
  41. fin_infra/crypto/insights.py +5 -6
  42. fin_infra/documents/add.py +11 -13
  43. fin_infra/documents/analysis.py +9 -9
  44. fin_infra/documents/ease.py +18 -17
  45. fin_infra/documents/models.py +7 -7
  46. fin_infra/documents/ocr.py +8 -8
  47. fin_infra/documents/storage.py +23 -14
  48. fin_infra/exceptions.py +1 -2
  49. fin_infra/goals/__init__.py +8 -8
  50. fin_infra/goals/add.py +36 -36
  51. fin_infra/goals/funding.py +4 -6
  52. fin_infra/goals/management.py +6 -7
  53. fin_infra/goals/milestones.py +2 -3
  54. fin_infra/goals/models.py +7 -11
  55. fin_infra/insights/__init__.py +12 -10
  56. fin_infra/insights/aggregator.py +1 -1
  57. fin_infra/investments/__init__.py +14 -9
  58. fin_infra/investments/add.py +53 -73
  59. fin_infra/investments/ease.py +16 -13
  60. fin_infra/investments/models.py +135 -69
  61. fin_infra/investments/providers/base.py +9 -15
  62. fin_infra/investments/providers/plaid.py +70 -55
  63. fin_infra/investments/providers/snaptrade.py +35 -53
  64. fin_infra/markets/__init__.py +16 -11
  65. fin_infra/models/__init__.py +10 -10
  66. fin_infra/models/accounts.py +2 -1
  67. fin_infra/models/brokerage.py +2 -1
  68. fin_infra/models/candle.py +1 -0
  69. fin_infra/models/money.py +1 -0
  70. fin_infra/models/quotes.py +4 -3
  71. fin_infra/models/tax.py +2 -1
  72. fin_infra/models/transactions.py +4 -4
  73. fin_infra/net_worth/__init__.py +7 -0
  74. fin_infra/net_worth/add.py +8 -5
  75. fin_infra/net_worth/aggregator.py +9 -6
  76. fin_infra/net_worth/calculator.py +8 -6
  77. fin_infra/net_worth/ease.py +36 -15
  78. fin_infra/net_worth/insights.py +4 -5
  79. fin_infra/net_worth/models.py +237 -116
  80. fin_infra/normalization/__init__.py +17 -15
  81. fin_infra/normalization/providers/exchangerate.py +5 -5
  82. fin_infra/obs/classifier.py +3 -3
  83. fin_infra/providers/banking/plaid_client.py +23 -22
  84. fin_infra/providers/banking/teller_client.py +14 -7
  85. fin_infra/providers/base.py +131 -14
  86. fin_infra/providers/brokerage/alpaca.py +7 -7
  87. fin_infra/providers/credit/experian.py +5 -0
  88. fin_infra/providers/market/alphavantage.py +6 -11
  89. fin_infra/providers/market/ccxt_crypto.py +25 -4
  90. fin_infra/providers/market/coingecko.py +5 -6
  91. fin_infra/providers/market/yahoo.py +23 -8
  92. fin_infra/providers/tax/__init__.py +1 -1
  93. fin_infra/providers/tax/irs.py +1 -1
  94. fin_infra/providers/tax/mock.py +8 -8
  95. fin_infra/providers/tax/taxbit.py +1 -1
  96. fin_infra/recurring/__init__.py +6 -6
  97. fin_infra/recurring/add.py +24 -12
  98. fin_infra/recurring/detector.py +8 -8
  99. fin_infra/recurring/detectors_llm.py +14 -13
  100. fin_infra/recurring/ease.py +3 -5
  101. fin_infra/recurring/insights.py +20 -19
  102. fin_infra/recurring/models.py +3 -3
  103. fin_infra/recurring/normalizer.py +3 -2
  104. fin_infra/recurring/normalizers.py +11 -10
  105. fin_infra/recurring/summary.py +13 -15
  106. fin_infra/scaffold/__init__.py +1 -1
  107. fin_infra/scaffold/budgets.py +9 -9
  108. fin_infra/scaffold/goals.py +5 -5
  109. fin_infra/security/__init__.py +8 -8
  110. fin_infra/security/encryption.py +6 -6
  111. fin_infra/security/models.py +7 -7
  112. fin_infra/security/pii_filter.py +6 -6
  113. fin_infra/security/pii_patterns.py +1 -1
  114. fin_infra/security/token_store.py +3 -1
  115. fin_infra/settings.py +2 -1
  116. fin_infra/tax/__init__.py +2 -2
  117. fin_infra/tax/add.py +3 -2
  118. fin_infra/tax/tlh.py +5 -5
  119. fin_infra/utils/http.py +5 -3
  120. fin_infra/utils/retry.py +2 -1
  121. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
  122. fin_infra-0.1.82.dist-info/RECORD +180 -0
  123. fin_infra-0.1.62.dist-info/RECORD +0 -180
  124. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  125. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  126. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
@@ -1,20 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import date, datetime, timedelta
4
+ from typing import Any, cast
4
5
 
5
6
  # Plaid SDK v25+ uses new API structure
6
7
  try:
7
8
  import plaid
8
9
  from plaid.api import plaid_api
10
+ from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
11
+ from plaid.model.accounts_get_request import AccountsGetRequest
9
12
  from plaid.model.country_code import CountryCode
13
+ from plaid.model.identity_get_request import IdentityGetRequest
10
14
  from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
11
15
  from plaid.model.link_token_create_request import LinkTokenCreateRequest
12
16
  from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
13
17
  from plaid.model.products import Products
14
18
  from plaid.model.transactions_get_request import TransactionsGetRequest
15
- from plaid.model.accounts_get_request import AccountsGetRequest
16
- from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
17
- from plaid.model.identity_get_request import IdentityGetRequest
18
19
 
19
20
  PLAID_AVAILABLE = True
20
21
  except Exception: # pragma: no cover - dynamic import guard
@@ -33,7 +34,7 @@ class PlaidClient(BankingProvider):
33
34
  environment: str | None = None,
34
35
  ) -> None:
35
36
  """Initialize Plaid client with either Settings object or individual parameters.
36
-
37
+
37
38
  Args:
38
39
  settings: Settings object (legacy pattern)
39
40
  client_id: Plaid client ID (preferred - from env or passed directly)
@@ -44,14 +45,14 @@ class PlaidClient(BankingProvider):
44
45
  raise RuntimeError(
45
46
  "plaid-python SDK not available or import failed; check installed version (requires v25+)"
46
47
  )
47
-
48
+
48
49
  # Support both patterns: Settings object or individual params
49
50
  if settings is not None:
50
51
  # Legacy pattern with Settings object
51
52
  client_id = client_id or settings.plaid_client_id
52
53
  secret = secret or settings.plaid_secret
53
54
  environment = environment or settings.plaid_env
54
-
55
+
55
56
  # Map environment string to Plaid Environment enum
56
57
  # Note: Plaid only has Sandbox and Production (no Development in SDK)
57
58
  env_str = environment or "sandbox"
@@ -60,22 +61,22 @@ class PlaidClient(BankingProvider):
60
61
  "development": plaid.Environment.Sandbox, # Map development to sandbox (Plaid SDK limitation)
61
62
  "production": plaid.Environment.Production,
62
63
  }
63
-
64
+
64
65
  if env_str not in env_map:
65
66
  raise ValueError(
66
67
  f"Invalid Plaid environment: '{env_str}'. "
67
68
  f"Must be one of: sandbox, development, production"
68
69
  )
69
-
70
+
70
71
  host = env_map[env_str]
71
-
72
+
72
73
  # Configure Plaid client (v8.0.0+ API)
73
74
  configuration = plaid.Configuration(
74
75
  host=host,
75
76
  api_key={
76
77
  "clientId": client_id,
77
78
  "secret": secret,
78
- }
79
+ },
79
80
  )
80
81
  api_client = plaid.ApiClient(configuration)
81
82
  self.client = plaid_api.PlaidApi(api_client)
@@ -85,18 +86,18 @@ class PlaidClient(BankingProvider):
85
86
  user=LinkTokenCreateRequestUser(client_user_id=user_id),
86
87
  client_name="fin-infra",
87
88
  products=[
88
- Products("auth"), # Account/routing numbers for ACH
89
- Products("transactions"), # Transaction history
90
- Products("liabilities"), # Credit cards, loans, student loans
91
- Products("investments"), # Brokerage, retirement accounts
92
- Products("assets"), # Asset reports for lending/verification
93
- Products("identity"), # Account holder info (name, email, phone)
89
+ Products("auth"), # Account/routing numbers for ACH
90
+ Products("transactions"), # Transaction history
91
+ Products("liabilities"), # Credit cards, loans, student loans
92
+ Products("investments"), # Brokerage, retirement accounts
93
+ Products("assets"), # Asset reports for lending/verification
94
+ Products("identity"), # Account holder info (name, email, phone)
94
95
  ],
95
96
  country_codes=[CountryCode("US")],
96
97
  language="en",
97
98
  )
98
99
  response = self.client.link_token_create(request)
99
- return response["link_token"]
100
+ return cast("str", response["link_token"])
100
101
 
101
102
  def exchange_public_token(self, public_token: str) -> dict:
102
103
  request = ItemPublicTokenExchangeRequest(public_token=public_token)
@@ -121,7 +122,7 @@ class PlaidClient(BankingProvider):
121
122
  start = end - timedelta(days=30)
122
123
  start_date = start_date or start.isoformat()
123
124
  end_date = end_date or end.isoformat()
124
-
125
+
125
126
  request = TransactionsGetRequest(
126
127
  access_token=access_token,
127
128
  start_date=date.fromisoformat(start_date),
@@ -135,19 +136,19 @@ class PlaidClient(BankingProvider):
135
136
  request = AccountsBalanceGetRequest(access_token=access_token)
136
137
  response = self.client.accounts_balance_get(request)
137
138
  accounts = [acc.to_dict() for acc in response["accounts"]]
138
-
139
+
139
140
  if account_id:
140
141
  # Filter to specific account
141
142
  for account in accounts:
142
143
  if account.get("account_id") == account_id:
143
144
  return {"balances": [account.get("balances", {})]}
144
145
  return {"balances": []}
145
-
146
+
146
147
  # Return all balances
147
148
  return {"balances": [acc.get("balances", {}) for acc in accounts]}
148
149
 
149
- def identity(self, access_token: str) -> dict:
150
+ def identity(self, access_token: str) -> dict[Any, Any]:
150
151
  """Fetch identity/account holder information."""
151
152
  request = IdentityGetRequest(access_token=access_token)
152
153
  response = self.client.identity_get(request)
153
- return response.to_dict()
154
+ return cast("dict[Any, Any]", response.to_dict())
@@ -23,8 +23,9 @@ Example:
23
23
  from __future__ import annotations
24
24
 
25
25
  import ssl
26
+ from typing import Any, cast
27
+
26
28
  import httpx
27
- from typing import Any
28
29
 
29
30
  from ..base import BankingProvider
30
31
 
@@ -93,7 +94,13 @@ class TellerClient(BankingProvider):
93
94
  ssl_context.load_cert_chain(certfile=cert_path, keyfile=key_path)
94
95
  client_kwargs["verify"] = ssl_context
95
96
 
96
- self.client = httpx.Client(**client_kwargs)
97
+ # Create client with explicit parameters to satisfy type checker
98
+ self.client = httpx.Client(
99
+ base_url=str(client_kwargs["base_url"]),
100
+ timeout=float(client_kwargs["timeout"]), # type: ignore[arg-type]
101
+ headers=client_kwargs["headers"], # type: ignore[arg-type]
102
+ verify=client_kwargs.get("verify", True), # type: ignore[arg-type]
103
+ )
97
104
 
98
105
  def _request(self, method: str, path: str, **kwargs: Any) -> Any:
99
106
  """Make HTTP request to Teller API with error handling.
@@ -139,7 +146,7 @@ class TellerClient(BankingProvider):
139
146
  "products": ["accounts", "transactions", "balances", "identity"],
140
147
  },
141
148
  )
142
- return response.get("enrollment_id", "")
149
+ return cast("str", response.get("enrollment_id", ""))
143
150
 
144
151
  def exchange_public_token(self, public_token: str) -> dict:
145
152
  """Exchange public token for access token.
@@ -186,7 +193,7 @@ class TellerClient(BankingProvider):
186
193
  auth=(access_token, ""),
187
194
  )
188
195
  response.raise_for_status()
189
- return response.json()
196
+ return cast("list[dict[Any, Any]]", response.json())
190
197
 
191
198
  def transactions(
192
199
  self,
@@ -229,7 +236,7 @@ class TellerClient(BankingProvider):
229
236
  params=params,
230
237
  )
231
238
  response.raise_for_status()
232
- return response.json()
239
+ return cast("list[dict[Any, Any]]", response.json())
233
240
 
234
241
  def balances(self, access_token: str, account_id: str | None = None) -> dict:
235
242
  """Fetch current balances.
@@ -261,7 +268,7 @@ class TellerClient(BankingProvider):
261
268
  )
262
269
 
263
270
  response.raise_for_status()
264
- return response.json()
271
+ return cast("dict[Any, Any]", response.json())
265
272
 
266
273
  def identity(self, access_token: str) -> dict:
267
274
  """Fetch identity/account holder information.
@@ -285,7 +292,7 @@ class TellerClient(BankingProvider):
285
292
  auth=(access_token, ""),
286
293
  )
287
294
  response.raise_for_status()
288
- return response.json()
295
+ return cast("dict[Any, Any]", response.json())
289
296
 
290
297
  def __del__(self) -> None:
291
298
  """Close HTTP client on cleanup."""
@@ -1,9 +1,34 @@
1
+ """Base provider ABCs for fin-infra.
2
+
3
+ This module defines abstract base classes for all financial data providers.
4
+ These are the canonical ABCs - use these instead of fin_infra.clients.
5
+
6
+ Sync vs Async Pattern:
7
+ Most providers use SYNCHRONOUS methods for simplicity. The exceptions are:
8
+ - InvestmentProvider: Uses async methods (get_holdings, get_investment_accounts)
9
+
10
+ If you need async, wrap sync providers with asyncio.to_thread():
11
+ import asyncio
12
+ result = await asyncio.to_thread(provider.quote, "AAPL")
13
+
14
+ Provider Categories:
15
+ - MarketDataProvider: Stock/equity quotes and historical data
16
+ - CryptoDataProvider: Cryptocurrency market data
17
+ - BankingProvider: Bank account aggregation (Plaid, Teller, MX)
18
+ - BrokerageProvider: Trading operations (Alpaca, Interactive Brokers)
19
+ - CreditProvider: Credit scores and reports
20
+ - TaxProvider: Tax documents and calculations
21
+ - IdentityProvider: Identity verification
22
+ - InvestmentProvider: Investment holdings (async)
23
+ """
24
+
1
25
  from __future__ import annotations
2
26
 
3
27
  from abc import ABC, abstractmethod
4
- from typing import Iterable, Sequence
28
+ from collections.abc import Iterable, Sequence
29
+ from typing import Any
5
30
 
6
- from ..models import Quote, Candle
31
+ from ..models import Candle, Quote
7
32
 
8
33
 
9
34
  class MarketDataProvider(ABC):
@@ -20,11 +45,11 @@ class MarketDataProvider(ABC):
20
45
 
21
46
  class CryptoDataProvider(ABC):
22
47
  @abstractmethod
23
- def ticker(self, symbol_pair: str) -> Quote:
48
+ def ticker(self, symbol_pair: str) -> Any:
24
49
  pass
25
50
 
26
51
  @abstractmethod
27
- def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> Sequence[Candle]:
52
+ def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> Any:
28
53
  pass
29
54
 
30
55
 
@@ -67,7 +92,15 @@ class BankingProvider(ABC):
67
92
  class BrokerageProvider(ABC):
68
93
  @abstractmethod
69
94
  def submit_order(
70
- self, symbol: str, qty: float, side: str, type_: str, time_in_force: str
95
+ self,
96
+ symbol: str,
97
+ qty: float,
98
+ side: str,
99
+ type_: str,
100
+ time_in_force: str,
101
+ limit_price: float | None = None,
102
+ stop_price: float | None = None,
103
+ client_order_id: str | None = None,
71
104
  ) -> dict:
72
105
  pass
73
106
 
@@ -75,6 +108,71 @@ class BrokerageProvider(ABC):
75
108
  def positions(self) -> Iterable[dict]:
76
109
  pass
77
110
 
111
+ @abstractmethod
112
+ def get_account(self) -> dict:
113
+ """Get trading account information."""
114
+ pass
115
+
116
+ @abstractmethod
117
+ def get_position(self, symbol: str) -> dict:
118
+ """Get position for a specific symbol."""
119
+ pass
120
+
121
+ @abstractmethod
122
+ def close_position(self, symbol: str) -> dict:
123
+ """Close a position (market sell/cover)."""
124
+ pass
125
+
126
+ @abstractmethod
127
+ def list_orders(self, status: str = "open", limit: int = 50) -> list[dict]:
128
+ """List orders."""
129
+ pass
130
+
131
+ @abstractmethod
132
+ def get_order(self, order_id: str) -> dict:
133
+ """Get order by ID."""
134
+ pass
135
+
136
+ @abstractmethod
137
+ def cancel_order(self, order_id: str) -> None:
138
+ """Cancel an order."""
139
+ pass
140
+
141
+ @abstractmethod
142
+ def get_portfolio_history(self, period: str = "1M", timeframe: str = "1D") -> dict:
143
+ """Get portfolio value history."""
144
+ pass
145
+
146
+ @abstractmethod
147
+ def create_watchlist(self, name: str, symbols: list[str] | None = None) -> dict:
148
+ """Create a new watchlist."""
149
+ pass
150
+
151
+ @abstractmethod
152
+ def list_watchlists(self) -> list[dict]:
153
+ """List all watchlists."""
154
+ pass
155
+
156
+ @abstractmethod
157
+ def get_watchlist(self, watchlist_id: str) -> dict:
158
+ """Get a watchlist by ID."""
159
+ pass
160
+
161
+ @abstractmethod
162
+ def delete_watchlist(self, watchlist_id: str) -> None:
163
+ """Delete a watchlist."""
164
+ pass
165
+
166
+ @abstractmethod
167
+ def add_to_watchlist(self, watchlist_id: str, symbol: str) -> dict:
168
+ """Add a symbol to a watchlist."""
169
+ pass
170
+
171
+ @abstractmethod
172
+ def remove_from_watchlist(self, watchlist_id: str, symbol: str) -> dict:
173
+ """Remove a symbol from a watchlist."""
174
+ pass
175
+
78
176
 
79
177
  class IdentityProvider(ABC):
80
178
  @abstractmethod
@@ -88,7 +186,12 @@ class IdentityProvider(ABC):
88
186
 
89
187
  class CreditProvider(ABC):
90
188
  @abstractmethod
91
- def get_credit_score(self, user_id: str, **kwargs) -> dict | None:
189
+ def get_credit_score(self, user_id: str, **kwargs: Any) -> Any:
190
+ pass
191
+
192
+ @abstractmethod
193
+ def get_credit_report(self, user_id: str, **kwargs: Any) -> Any:
194
+ """Retrieve full credit report for a user."""
92
195
  pass
93
196
 
94
197
 
@@ -96,44 +199,58 @@ class TaxProvider(ABC):
96
199
  """Provider for tax data and document retrieval."""
97
200
 
98
201
  @abstractmethod
99
- def get_tax_forms(self, user_id: str, tax_year: int, **kwargs) -> list[dict]:
202
+ def get_tax_forms(self, user_id: str, tax_year: int, **kwargs: Any) -> Any:
100
203
  """Retrieve tax forms for a user and tax year."""
101
204
  pass
102
205
 
103
206
  @abstractmethod
104
- def get_tax_document(self, document_id: str, **kwargs) -> dict:
207
+ def get_tax_documents(self, user_id: str, tax_year: int, **kwargs: Any) -> Any:
208
+ """Retrieve tax documents for a user and tax year."""
209
+ pass
210
+
211
+ @abstractmethod
212
+ def get_tax_document(self, document_id: str, **kwargs: Any) -> Any:
105
213
  """Retrieve a specific tax document by ID."""
106
214
  pass
107
215
 
108
216
  @abstractmethod
109
- def calculate_crypto_gains(self, transactions: list[dict], **kwargs) -> dict:
217
+ def calculate_crypto_gains(self, *args: Any, **kwargs: Any) -> Any:
110
218
  """Calculate capital gains from crypto transactions."""
111
219
  pass
112
220
 
221
+ @abstractmethod
222
+ def calculate_tax_liability(
223
+ self,
224
+ *args: Any,
225
+ **kwargs: Any,
226
+ ) -> Any:
227
+ """Calculate estimated tax liability."""
228
+ pass
229
+
113
230
 
114
231
  class InvestmentProvider(ABC):
115
232
  """Provider for investment holdings and portfolio data (Plaid, SnapTrade).
116
-
233
+
117
234
  This is a minimal ABC for type checking. The full implementation with
118
235
  all abstract methods is in fin_infra.investments.providers.base.InvestmentProvider.
119
-
236
+
120
237
  Abstract Methods (defined in full implementation):
121
238
  - get_holdings(access_token, account_ids) -> List[Holding]
122
239
  - get_transactions(access_token, start_date, end_date, account_ids) -> List[InvestmentTransaction]
123
240
  - get_securities(access_token, security_ids) -> List[Security]
124
241
  - get_investment_accounts(access_token) -> List[InvestmentAccount]
125
-
242
+
126
243
  Example:
127
244
  >>> from fin_infra.investments import easy_investments
128
245
  >>> provider = easy_investments(provider="plaid")
129
246
  >>> holdings = await provider.get_holdings(access_token)
130
247
  """
131
-
248
+
132
249
  @abstractmethod
133
250
  async def get_holdings(self, access_token: str, account_ids: list[str] | None = None) -> list:
134
251
  """Fetch holdings for investment accounts."""
135
252
  pass
136
-
253
+
137
254
  @abstractmethod
138
255
  async def get_investment_accounts(self, access_token: str) -> list:
139
256
  """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
@@ -8,16 +8,15 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  import time
11
- from typing import Sequence
11
+ from collections.abc import Sequence
12
+ from datetime import UTC, datetime
12
13
  from decimal import Decimal
13
- from datetime import datetime, timezone
14
14
 
15
15
  import httpx
16
16
 
17
- from .base import MarketDataProvider
18
- from ...models import Quote, Candle
17
+ from ...models import Candle, Quote
19
18
  from ...settings import Settings
20
-
19
+ from .base import MarketDataProvider
21
20
 
22
21
  _BASE = "https://www.alphavantage.co/query"
23
22
 
@@ -128,11 +127,7 @@ class AlphaVantageMarketData(MarketDataProvider):
128
127
 
129
128
  price = Decimal(str(q.get("05. price", "0")))
130
129
  ts = q.get("07. latest trading day")
131
- as_of = (
132
- datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=timezone.utc)
133
- if ts
134
- else datetime.now(timezone.utc)
135
- )
130
+ as_of = datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=UTC) if ts else datetime.now(UTC)
136
131
 
137
132
  return Quote(symbol=symbol.upper(), price=price, as_of=as_of)
138
133
 
@@ -202,7 +197,7 @@ class AlphaVantageMarketData(MarketDataProvider):
202
197
  out: list[Candle] = []
203
198
  for d, vals in list(time_series.items())[:limit]:
204
199
  try:
205
- dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc)
200
+ dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=UTC)
206
201
  ts_ms = int(dt.timestamp() * 1000)
207
202
  out.append(
208
203
  Candle(
@@ -1,28 +1,49 @@
1
1
  from __future__ import annotations
2
2
 
3
- import ccxt
3
+ from typing import Any, cast
4
+
5
+ try:
6
+ import ccxt
7
+
8
+ HAS_CCXT = True
9
+ except ImportError: # pragma: no cover
10
+ HAS_CCXT = False
11
+ ccxt = None
4
12
 
5
13
  from ..base import CryptoDataProvider
6
14
 
7
15
 
16
+ def _require_ccxt() -> None:
17
+ """Raise ImportError if ccxt is not installed."""
18
+ if not HAS_CCXT:
19
+ raise ImportError(
20
+ "Crypto exchange support requires the 'ccxt' package. "
21
+ "Install with: pip install fin-infra[crypto] or pip install fin-infra[markets]"
22
+ )
23
+
24
+
8
25
  class CCXTCryptoData(CryptoDataProvider):
9
26
  """Exchange-agnostic crypto market data using CCXT."""
10
27
 
11
28
  def __init__(self, exchange: str = "binance") -> None:
29
+ _require_ccxt()
12
30
  if not hasattr(ccxt, exchange):
13
31
  raise ValueError(f"Unknown exchange '{exchange}' in ccxt")
14
32
  self.exchange = getattr(ccxt, exchange)()
15
33
  # Defer load_markets to first call to avoid network on construction
16
34
  self._markets_loaded = False
17
35
 
18
- def ticker(self, symbol_pair: str) -> dict:
36
+ def ticker(self, symbol_pair: str) -> dict[Any, Any]:
19
37
  if not self._markets_loaded:
20
38
  self.exchange.load_markets()
21
39
  self._markets_loaded = True
22
- return self.exchange.fetch_ticker(symbol_pair)
40
+ return cast("dict[Any, Any]", self.exchange.fetch_ticker(symbol_pair))
23
41
 
24
42
  def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[list[float]]:
25
43
  if not self._markets_loaded:
26
44
  self.exchange.load_markets()
27
45
  self._markets_loaded = True
28
- return self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit)
46
+ return cast(
47
+ "list[list[float]]",
48
+ self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
49
+ )
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import httpx
4
+ from datetime import UTC, datetime
5
5
  from decimal import Decimal
6
- from datetime import datetime, timezone
7
6
 
7
+ import httpx
8
+
9
+ from ...models import Candle, Quote
8
10
  from ..base import CryptoDataProvider
9
- from ...models import Quote, Candle
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
@@ -30,9 +31,7 @@ class CoinGeckoCryptoData(CryptoDataProvider):
30
31
  except Exception as e:
31
32
  logger.warning("CoinGecko ticker fetch failed for %s: %s", symbol_pair, e)
32
33
  price = 0
33
- return Quote(
34
- symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(timezone.utc)
35
- )
34
+ return Quote(symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(UTC))
36
35
 
37
36
  def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[Candle]:
38
37
  # CoinGecko provides market_chart with daily data; map timeframe crudely
@@ -10,14 +10,29 @@ For production, consider Alpha Vantage or other official providers.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import Sequence
13
+ from collections.abc import Sequence
14
+ from datetime import UTC, datetime
14
15
  from decimal import Decimal
15
- from datetime import datetime, timezone
16
16
 
17
- from yahooquery import Ticker
17
+ try:
18
+ from yahooquery import Ticker
18
19
 
20
+ HAS_YAHOOQUERY = True
21
+ except ImportError: # pragma: no cover
22
+ HAS_YAHOOQUERY = False
23
+ Ticker = None
24
+
25
+ from ...models import Candle, Quote
19
26
  from .base import MarketDataProvider
20
- from ...models import Quote, Candle
27
+
28
+
29
+ def _require_yahooquery() -> None:
30
+ """Raise ImportError if yahooquery is not installed."""
31
+ if not HAS_YAHOOQUERY:
32
+ raise ImportError(
33
+ "Yahoo Finance support requires the 'yahooquery' package. "
34
+ "Install with: pip install fin-infra[yahoo] or pip install fin-infra[markets]"
35
+ )
21
36
 
22
37
 
23
38
  class YahooFinanceMarketData(MarketDataProvider):
@@ -42,7 +57,7 @@ class YahooFinanceMarketData(MarketDataProvider):
42
57
 
43
58
  def __init__(self) -> None:
44
59
  """Initialize Yahoo Finance provider (no configuration needed)."""
45
- pass
60
+ _require_yahooquery()
46
61
 
47
62
  def quote(self, symbol: str) -> Quote:
48
63
  """Get real-time quote for a symbol.
@@ -78,9 +93,9 @@ class YahooFinanceMarketData(MarketDataProvider):
78
93
  ts_raw = data.get("regularMarketTime")
79
94
  if ts_raw:
80
95
  # Convert Unix timestamp to datetime
81
- as_of = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
96
+ as_of = datetime.fromtimestamp(ts_raw, tz=UTC)
82
97
  else:
83
- as_of = datetime.now(timezone.utc)
98
+ as_of = datetime.now(UTC)
84
99
 
85
100
  return Quote(
86
101
  symbol=symbol.upper(),
@@ -135,7 +150,7 @@ class YahooFinanceMarketData(MarketDataProvider):
135
150
 
136
151
  # Ensure timezone aware
137
152
  if dt.tzinfo is None:
138
- dt = dt.replace(tzinfo=timezone.utc)
153
+ dt = dt.replace(tzinfo=UTC)
139
154
 
140
155
  ts_ms = int(dt.timestamp() * 1000)
141
156
 
@@ -1,7 +1,7 @@
1
1
  """Tax providers package."""
2
2
 
3
- from .mock import MockTaxProvider
4
3
  from .irs import IRSProvider
4
+ from .mock import MockTaxProvider
5
5
  from .taxbit import TaxBitProvider
6
6
 
7
7
  __all__ = [