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.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +30 -32
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +19 -26
- fin_infra/analytics/projections.py +1 -3
- 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 +33 -31
- fin_infra/banking/history.py +11 -12
- fin_infra/banking/utils.py +116 -110
- fin_infra/brokerage/__init__.py +27 -27
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +3 -3
- fin_infra/budgets/tracker.py +4 -5
- fin_infra/cashflows/__init__.py +8 -10
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +17 -19
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +21 -18
- fin_infra/categorization/llm_layer.py +10 -10
- fin_infra/categorization/models.py +1 -1
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +13 -22
- fin_infra/chat/planning.py +57 -1
- fin_infra/cli/cmds/scaffold_cmds.py +11 -12
- fin_infra/clients/__init__.py +23 -1
- fin_infra/clients/base.py +1 -1
- fin_infra/clients/plaid.py +2 -2
- fin_infra/compliance/__init__.py +7 -6
- fin_infra/credit/add.py +7 -7
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +19 -19
- fin_infra/crypto/__init__.py +8 -10
- fin_infra/crypto/insights.py +5 -6
- fin_infra/documents/add.py +11 -13
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +18 -17
- fin_infra/documents/models.py +7 -7
- fin_infra/documents/ocr.py +8 -8
- fin_infra/documents/storage.py +23 -14
- fin_infra/exceptions.py +1 -2
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +36 -36
- fin_infra/goals/funding.py +4 -6
- fin_infra/goals/management.py +6 -7
- fin_infra/goals/milestones.py +2 -3
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +12 -10
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +14 -9
- fin_infra/investments/add.py +53 -73
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +135 -69
- fin_infra/investments/providers/base.py +9 -15
- fin_infra/investments/providers/plaid.py +70 -55
- fin_infra/investments/providers/snaptrade.py +35 -53
- fin_infra/markets/__init__.py +16 -11
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +4 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +9 -6
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -5
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +17 -15
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +23 -22
- fin_infra/providers/banking/teller_client.py +14 -7
- fin_infra/providers/base.py +131 -14
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +25 -4
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +23 -8
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +8 -8
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +24 -12
- fin_infra/recurring/detector.py +8 -8
- fin_infra/recurring/detectors_llm.py +14 -13
- fin_infra/recurring/ease.py +3 -5
- fin_infra/recurring/insights.py +20 -19
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +11 -10
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +5 -5
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +2 -2
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +5 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.62.dist-info/RECORD +0 -180
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {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"),
|
|
89
|
-
Products("transactions"),
|
|
90
|
-
Products("liabilities"),
|
|
91
|
-
Products("investments"),
|
|
92
|
-
Products("assets"),
|
|
93
|
-
Products("identity"),
|
|
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
|
-
|
|
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."""
|
fin_infra/providers/base.py
CHANGED
|
@@ -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
|
|
28
|
+
from collections.abc import Iterable, Sequence
|
|
29
|
+
from typing import Any
|
|
5
30
|
|
|
6
|
-
from ..models import
|
|
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) ->
|
|
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) ->
|
|
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,
|
|
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) ->
|
|
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) ->
|
|
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
|
|
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,
|
|
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
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from datetime import UTC, datetime
|
|
12
13
|
from decimal import Decimal
|
|
13
|
-
from datetime import datetime, timezone
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
17
|
-
from
|
|
18
|
-
from ...models import Quote, Candle
|
|
17
|
+
from ...models import Candle, Quote
|
|
19
18
|
from ...settings import Settings
|
|
20
|
-
|
|
19
|
+
from .base import MarketDataProvider
|
|
21
20
|
|
|
22
21
|
_BASE = "https://www.alphavantage.co/query"
|
|
23
22
|
|
|
@@ -128,11 +127,7 @@ class AlphaVantageMarketData(MarketDataProvider):
|
|
|
128
127
|
|
|
129
128
|
price = Decimal(str(q.get("05. price", "0")))
|
|
130
129
|
ts = q.get("07. latest trading day")
|
|
131
|
-
as_of = (
|
|
132
|
-
datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
133
|
-
if ts
|
|
134
|
-
else datetime.now(timezone.utc)
|
|
135
|
-
)
|
|
130
|
+
as_of = datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=UTC) if ts else datetime.now(UTC)
|
|
136
131
|
|
|
137
132
|
return Quote(symbol=symbol.upper(), price=price, as_of=as_of)
|
|
138
133
|
|
|
@@ -202,7 +197,7 @@ class AlphaVantageMarketData(MarketDataProvider):
|
|
|
202
197
|
out: list[Candle] = []
|
|
203
198
|
for d, vals in list(time_series.items())[:limit]:
|
|
204
199
|
try:
|
|
205
|
-
dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=
|
|
200
|
+
dt = datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
206
201
|
ts_ms = int(dt.timestamp() * 1000)
|
|
207
202
|
out.append(
|
|
208
203
|
Candle(
|
|
@@ -1,28 +1,49 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
|
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
|
|
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
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from datetime import UTC, datetime
|
|
14
15
|
from decimal import Decimal
|
|
15
|
-
from datetime import datetime, timezone
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
try:
|
|
18
|
+
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
|
-
|
|
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
|
-
|
|
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=
|
|
96
|
+
as_of = datetime.fromtimestamp(ts_raw, tz=UTC)
|
|
82
97
|
else:
|
|
83
|
-
as_of = datetime.now(
|
|
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=
|
|
153
|
+
dt = dt.replace(tzinfo=UTC)
|
|
139
154
|
|
|
140
155
|
ts_ms = int(dt.timestamp() * 1000)
|
|
141
156
|
|