fin-infra 0.1.69__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 +21 -21
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +6 -6
- fin_infra/analytics/projections.py +1 -3
- fin_infra/banking/__init__.py +27 -28
- fin_infra/banking/history.py +8 -9
- fin_infra/banking/utils.py +27 -26
- fin_infra/brokerage/__init__.py +22 -24
- 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 +2 -2
- fin_infra/cashflows/__init__.py +3 -3
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/engine.py +17 -14
- fin_infra/categorization/llm_layer.py +7 -6
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +6 -6
- fin_infra/chat/planning.py +0 -1
- fin_infra/cli/cmds/scaffold_cmds.py +10 -11
- 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 +5 -4
- fin_infra/credit/add.py +6 -7
- fin_infra/credit/experian/auth.py +2 -2
- fin_infra/credit/experian/client.py +1 -1
- fin_infra/credit/experian/provider.py +4 -4
- fin_infra/crypto/__init__.py +7 -9
- fin_infra/crypto/insights.py +4 -3
- fin_infra/documents/add.py +6 -8
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +14 -14
- fin_infra/documents/models.py +4 -4
- fin_infra/documents/ocr.py +7 -7
- fin_infra/documents/storage.py +21 -13
- fin_infra/exceptions.py +0 -1
- 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 +2 -3
- fin_infra/goals/milestones.py +1 -2
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +6 -3
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +1 -1
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/models.py +5 -5
- fin_infra/investments/providers/base.py +8 -9
- fin_infra/investments/providers/plaid.py +52 -26
- fin_infra/investments/providers/snaptrade.py +19 -19
- fin_infra/markets/__init__.py +5 -3
- fin_infra/models/__init__.py +10 -10
- 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 +3 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/aggregator.py +4 -2
- fin_infra/net_worth/insights.py +0 -1
- fin_infra/normalization/__init__.py +2 -2
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +1 -1
- fin_infra/providers/banking/plaid_client.py +5 -5
- fin_infra/providers/banking/teller_client.py +7 -6
- fin_infra/providers/base.py +27 -2
- fin_infra/providers/brokerage/alpaca.py +3 -3
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +19 -3
- 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 +5 -5
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +5 -4
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +6 -6
- fin_infra/recurring/ease.py +2 -4
- fin_infra/recurring/insights.py +13 -13
- fin_infra/recurring/normalizer.py +1 -1
- fin_infra/recurring/normalizers.py +4 -4
- fin_infra/recurring/summary.py +13 -15
- 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/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -8
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.69.dist-info/RECORD +0 -180
- {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
|
@@ -30,6 +30,7 @@ print(f"Net Worth: ${snapshot.total_net_worth:,.2f}")
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
import asyncio
|
|
33
|
+
import logging
|
|
33
34
|
import uuid
|
|
34
35
|
from datetime import datetime
|
|
35
36
|
from typing import Any
|
|
@@ -47,6 +48,8 @@ from fin_infra.net_worth.models import (
|
|
|
47
48
|
NetWorthSnapshot,
|
|
48
49
|
)
|
|
49
50
|
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
50
53
|
|
|
51
54
|
class NetWorthAggregator:
|
|
52
55
|
"""
|
|
@@ -219,8 +222,7 @@ class NetWorthAggregator:
|
|
|
219
222
|
|
|
220
223
|
for i, result in enumerate(results):
|
|
221
224
|
if isinstance(result, BaseException):
|
|
222
|
-
|
|
223
|
-
print(f"Provider {providers_used[i]} failed: {result}")
|
|
225
|
+
logger.warning("Provider %s failed: %s", providers_used[i], result)
|
|
224
226
|
continue
|
|
225
227
|
|
|
226
228
|
# result is now tuple[list[AssetDetail], list[LiabilityDetail]]
|
fin_infra/net_worth/insights.py
CHANGED
|
@@ -31,7 +31,6 @@ from typing import Any
|
|
|
31
31
|
|
|
32
32
|
from pydantic import BaseModel, Field
|
|
33
33
|
|
|
34
|
-
|
|
35
34
|
# ============================================================================
|
|
36
35
|
# Pydantic Schemas (Structured Output)
|
|
37
36
|
# ============================================================================
|
|
@@ -116,11 +116,11 @@ def add_normalization(
|
|
|
116
116
|
- Scoped docs at {prefix}/docs for standalone documentation
|
|
117
117
|
"""
|
|
118
118
|
# Import FastAPI dependencies
|
|
119
|
-
from fastapi import
|
|
119
|
+
from fastapi import HTTPException, Query
|
|
120
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
120
121
|
|
|
121
122
|
# Import svc-infra public router (no auth - utility endpoints)
|
|
122
123
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
123
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
124
124
|
|
|
125
125
|
# Get normalization services
|
|
126
126
|
resolver, converter = easy_normalization(api_key=api_key)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from datetime import date as DateType
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import cast
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ __all__ = [
|
|
|
19
19
|
class ExchangeRateClient:
|
|
20
20
|
"""Client for exchangerate-api.io API."""
|
|
21
21
|
|
|
22
|
-
def __init__(self, api_key:
|
|
22
|
+
def __init__(self, api_key: str | None = None):
|
|
23
23
|
"""
|
|
24
24
|
Initialize exchange rate client.
|
|
25
25
|
|
|
@@ -66,10 +66,10 @@ class ExchangeRateClient:
|
|
|
66
66
|
raise ExchangeRateAPIError(
|
|
67
67
|
f"API returned error: {data.get('error-type', 'unknown')}"
|
|
68
68
|
)
|
|
69
|
-
return cast(dict[str, float], data["conversion_rates"])
|
|
69
|
+
return cast("dict[str, float]", data["conversion_rates"])
|
|
70
70
|
else:
|
|
71
71
|
# Free tier response format
|
|
72
|
-
return cast(dict[str, float], data["rates"])
|
|
72
|
+
return cast("dict[str, float]", data["rates"])
|
|
73
73
|
|
|
74
74
|
except httpx.HTTPError as e:
|
|
75
75
|
raise ExchangeRateAPIError(f"HTTP error fetching rates: {e}")
|
|
@@ -77,7 +77,7 @@ class ExchangeRateClient:
|
|
|
77
77
|
raise ExchangeRateAPIError(f"Invalid API response: {e}")
|
|
78
78
|
|
|
79
79
|
async def get_rate(
|
|
80
|
-
self, from_currency: str, to_currency: str, date:
|
|
80
|
+
self, from_currency: str, to_currency: str, date: DateType | None = None
|
|
81
81
|
) -> ExchangeRate:
|
|
82
82
|
"""
|
|
83
83
|
Get exchange rate between two currencies.
|
fin_infra/obs/classifier.py
CHANGED
|
@@ -7,15 +7,15 @@ from typing import Any, cast
|
|
|
7
7
|
try:
|
|
8
8
|
import plaid
|
|
9
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
|
|
10
12
|
from plaid.model.country_code import CountryCode
|
|
13
|
+
from plaid.model.identity_get_request import IdentityGetRequest
|
|
11
14
|
from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
|
|
12
15
|
from plaid.model.link_token_create_request import LinkTokenCreateRequest
|
|
13
16
|
from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
|
|
14
17
|
from plaid.model.products import Products
|
|
15
18
|
from plaid.model.transactions_get_request import TransactionsGetRequest
|
|
16
|
-
from plaid.model.accounts_get_request import AccountsGetRequest
|
|
17
|
-
from plaid.model.accounts_balance_get_request import AccountsBalanceGetRequest
|
|
18
|
-
from plaid.model.identity_get_request import IdentityGetRequest
|
|
19
19
|
|
|
20
20
|
PLAID_AVAILABLE = True
|
|
21
21
|
except Exception: # pragma: no cover - dynamic import guard
|
|
@@ -97,7 +97,7 @@ class PlaidClient(BankingProvider):
|
|
|
97
97
|
language="en",
|
|
98
98
|
)
|
|
99
99
|
response = self.client.link_token_create(request)
|
|
100
|
-
return cast(str, response["link_token"])
|
|
100
|
+
return cast("str", response["link_token"])
|
|
101
101
|
|
|
102
102
|
def exchange_public_token(self, public_token: str) -> dict:
|
|
103
103
|
request = ItemPublicTokenExchangeRequest(public_token=public_token)
|
|
@@ -151,4 +151,4 @@ class PlaidClient(BankingProvider):
|
|
|
151
151
|
"""Fetch identity/account holder information."""
|
|
152
152
|
request = IdentityGetRequest(access_token=access_token)
|
|
153
153
|
response = self.client.identity_get(request)
|
|
154
|
-
return cast(dict[Any, Any], response.to_dict())
|
|
154
|
+
return cast("dict[Any, Any]", response.to_dict())
|
|
@@ -23,9 +23,10 @@ Example:
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import ssl
|
|
26
|
-
import httpx
|
|
27
26
|
from typing import Any, cast
|
|
28
27
|
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
29
30
|
from ..base import BankingProvider
|
|
30
31
|
|
|
31
32
|
|
|
@@ -145,7 +146,7 @@ class TellerClient(BankingProvider):
|
|
|
145
146
|
"products": ["accounts", "transactions", "balances", "identity"],
|
|
146
147
|
},
|
|
147
148
|
)
|
|
148
|
-
return cast(str, response.get("enrollment_id", ""))
|
|
149
|
+
return cast("str", response.get("enrollment_id", ""))
|
|
149
150
|
|
|
150
151
|
def exchange_public_token(self, public_token: str) -> dict:
|
|
151
152
|
"""Exchange public token for access token.
|
|
@@ -192,7 +193,7 @@ class TellerClient(BankingProvider):
|
|
|
192
193
|
auth=(access_token, ""),
|
|
193
194
|
)
|
|
194
195
|
response.raise_for_status()
|
|
195
|
-
return cast(list[dict[Any, Any]], response.json())
|
|
196
|
+
return cast("list[dict[Any, Any]]", response.json())
|
|
196
197
|
|
|
197
198
|
def transactions(
|
|
198
199
|
self,
|
|
@@ -235,7 +236,7 @@ class TellerClient(BankingProvider):
|
|
|
235
236
|
params=params,
|
|
236
237
|
)
|
|
237
238
|
response.raise_for_status()
|
|
238
|
-
return cast(list[dict[Any, Any]], response.json())
|
|
239
|
+
return cast("list[dict[Any, Any]]", response.json())
|
|
239
240
|
|
|
240
241
|
def balances(self, access_token: str, account_id: str | None = None) -> dict:
|
|
241
242
|
"""Fetch current balances.
|
|
@@ -267,7 +268,7 @@ class TellerClient(BankingProvider):
|
|
|
267
268
|
)
|
|
268
269
|
|
|
269
270
|
response.raise_for_status()
|
|
270
|
-
return cast(dict[Any, Any], response.json())
|
|
271
|
+
return cast("dict[Any, Any]", response.json())
|
|
271
272
|
|
|
272
273
|
def identity(self, access_token: str) -> dict:
|
|
273
274
|
"""Fetch identity/account holder information.
|
|
@@ -291,7 +292,7 @@ class TellerClient(BankingProvider):
|
|
|
291
292
|
auth=(access_token, ""),
|
|
292
293
|
)
|
|
293
294
|
response.raise_for_status()
|
|
294
|
-
return cast(dict[Any, Any], response.json())
|
|
295
|
+
return cast("dict[Any, Any]", response.json())
|
|
295
296
|
|
|
296
297
|
def __del__(self) -> None:
|
|
297
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):
|
|
@@ -314,8 +314,8 @@ class AlpacaBrokerage(BrokerageProvider):
|
|
|
314
314
|
Alpaca entities have a _raw attribute with the API response data.
|
|
315
315
|
"""
|
|
316
316
|
if hasattr(obj, "_raw"):
|
|
317
|
-
return cast(dict[Any, Any], obj._raw)
|
|
317
|
+
return cast("dict[Any, Any]", obj._raw)
|
|
318
318
|
elif hasattr(obj, "__dict__"):
|
|
319
|
-
return cast(dict[Any, Any], obj.__dict__)
|
|
319
|
+
return cast("dict[Any, Any]", obj.__dict__)
|
|
320
320
|
else:
|
|
321
|
-
return cast(dict[Any, Any], obj)
|
|
321
|
+
return cast("dict[Any, Any]", obj)
|
|
@@ -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(
|
|
@@ -2,15 +2,31 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any, cast
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
import ccxt
|
|
7
|
+
|
|
8
|
+
HAS_CCXT = True
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
HAS_CCXT = False
|
|
11
|
+
ccxt = None
|
|
6
12
|
|
|
7
13
|
from ..base import CryptoDataProvider
|
|
8
14
|
|
|
9
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
|
+
|
|
10
25
|
class CCXTCryptoData(CryptoDataProvider):
|
|
11
26
|
"""Exchange-agnostic crypto market data using CCXT."""
|
|
12
27
|
|
|
13
28
|
def __init__(self, exchange: str = "binance") -> None:
|
|
29
|
+
_require_ccxt()
|
|
14
30
|
if not hasattr(ccxt, exchange):
|
|
15
31
|
raise ValueError(f"Unknown exchange '{exchange}' in ccxt")
|
|
16
32
|
self.exchange = getattr(ccxt, exchange)()
|
|
@@ -21,13 +37,13 @@ class CCXTCryptoData(CryptoDataProvider):
|
|
|
21
37
|
if not self._markets_loaded:
|
|
22
38
|
self.exchange.load_markets()
|
|
23
39
|
self._markets_loaded = True
|
|
24
|
-
return cast(dict[Any, Any], self.exchange.fetch_ticker(symbol_pair))
|
|
40
|
+
return cast("dict[Any, Any]", self.exchange.fetch_ticker(symbol_pair))
|
|
25
41
|
|
|
26
42
|
def ohlcv(self, symbol_pair: str, timeframe: str = "1d", limit: int = 100) -> list[list[float]]:
|
|
27
43
|
if not self._markets_loaded:
|
|
28
44
|
self.exchange.load_markets()
|
|
29
45
|
self._markets_loaded = True
|
|
30
46
|
return cast(
|
|
31
|
-
list[list[float]],
|
|
47
|
+
"list[list[float]]",
|
|
32
48
|
self.exchange.fetch_ohlcv(symbol_pair, timeframe=timeframe, limit=limit),
|
|
33
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
|
|
fin_infra/providers/tax/irs.py
CHANGED
fin_infra/providers/tax/mock.py
CHANGED
|
@@ -14,14 +14,14 @@ from datetime import date, datetime
|
|
|
14
14
|
from decimal import Decimal
|
|
15
15
|
|
|
16
16
|
from fin_infra.models.tax import (
|
|
17
|
+
CryptoTaxReport,
|
|
18
|
+
CryptoTransaction,
|
|
17
19
|
TaxDocument,
|
|
18
|
-
TaxFormW2,
|
|
19
|
-
TaxForm1099INT,
|
|
20
|
-
TaxForm1099DIV,
|
|
21
20
|
TaxForm1099B,
|
|
21
|
+
TaxForm1099DIV,
|
|
22
|
+
TaxForm1099INT,
|
|
22
23
|
TaxForm1099MISC,
|
|
23
|
-
|
|
24
|
-
CryptoTaxReport,
|
|
24
|
+
TaxFormW2,
|
|
25
25
|
TaxLiability,
|
|
26
26
|
)
|
|
27
27
|
from fin_infra.providers.base import TaxProvider
|
fin_infra/recurring/__init__.py
CHANGED
|
@@ -46,18 +46,18 @@ from .models import (
|
|
|
46
46
|
SubscriptionDetection,
|
|
47
47
|
SubscriptionStats,
|
|
48
48
|
)
|
|
49
|
-
from .summary import (
|
|
50
|
-
CancellationOpportunity,
|
|
51
|
-
RecurringItem,
|
|
52
|
-
RecurringSummary,
|
|
53
|
-
get_recurring_summary,
|
|
54
|
-
)
|
|
55
49
|
from .normalizer import (
|
|
56
50
|
FuzzyMatcher,
|
|
57
51
|
get_canonical_merchant,
|
|
58
52
|
is_generic_merchant,
|
|
59
53
|
normalize_merchant,
|
|
60
54
|
)
|
|
55
|
+
from .summary import (
|
|
56
|
+
CancellationOpportunity,
|
|
57
|
+
RecurringItem,
|
|
58
|
+
RecurringSummary,
|
|
59
|
+
get_recurring_summary,
|
|
60
|
+
)
|
|
61
61
|
|
|
62
62
|
__all__ = [
|
|
63
63
|
# Easy builders
|
fin_infra/recurring/add.py
CHANGED
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import time
|
|
13
13
|
from datetime import datetime, timedelta
|
|
14
|
-
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
15
|
|
|
16
16
|
from .ease import easy_recurring_detection
|
|
17
17
|
from .models import (
|
|
@@ -24,6 +24,7 @@ from .models import (
|
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from fastapi import FastAPI
|
|
27
|
+
|
|
27
28
|
from .detector import RecurringDetector
|
|
28
29
|
|
|
29
30
|
|
|
@@ -35,9 +36,9 @@ def add_recurring_detection(
|
|
|
35
36
|
date_tolerance_days: int = 7,
|
|
36
37
|
enable_llm: bool = False,
|
|
37
38
|
llm_provider: str = "google",
|
|
38
|
-
llm_model:
|
|
39
|
+
llm_model: str | None = None,
|
|
39
40
|
include_in_schema: bool = True,
|
|
40
|
-
) ->
|
|
41
|
+
) -> RecurringDetector:
|
|
41
42
|
"""
|
|
42
43
|
Add recurring transaction detection endpoints to FastAPI app.
|
|
43
44
|
|
|
@@ -242,7 +243,7 @@ def add_recurring_detection(
|
|
|
242
243
|
@router.get("/summary")
|
|
243
244
|
async def get_recurring_summary(
|
|
244
245
|
user_id: str,
|
|
245
|
-
category_map:
|
|
246
|
+
category_map: dict[str, str] | None = None,
|
|
246
247
|
):
|
|
247
248
|
"""
|
|
248
249
|
Get comprehensive recurring transaction summary.
|
fin_infra/recurring/detector.py
CHANGED
|
@@ -17,15 +17,15 @@ from __future__ import annotations
|
|
|
17
17
|
import statistics
|
|
18
18
|
from collections import defaultdict
|
|
19
19
|
from datetime import datetime, timedelta
|
|
20
|
-
from typing import
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
21
|
|
|
22
22
|
from .models import CadenceType, PatternType, RecurringPattern
|
|
23
23
|
from .normalizer import get_canonical_merchant, is_generic_merchant
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
|
-
from .normalizers import MerchantNormalizer
|
|
27
26
|
from .detectors_llm import VariableDetectorLLM
|
|
28
27
|
from .insights import SubscriptionInsightsGenerator
|
|
28
|
+
from .normalizers import MerchantNormalizer
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class Transaction:
|
|
@@ -59,8 +59,8 @@ class PatternDetector:
|
|
|
59
59
|
min_occurrences: int = 3,
|
|
60
60
|
amount_tolerance: float = 0.02,
|
|
61
61
|
date_tolerance_days: int = 7,
|
|
62
|
-
merchant_normalizer:
|
|
63
|
-
variable_detector_llm:
|
|
62
|
+
merchant_normalizer: MerchantNormalizer | None = None,
|
|
63
|
+
variable_detector_llm: VariableDetectorLLM | None = None,
|
|
64
64
|
):
|
|
65
65
|
"""
|
|
66
66
|
Initialize pattern detector.
|
|
@@ -512,9 +512,9 @@ class RecurringDetector:
|
|
|
512
512
|
min_occurrences: int = 3,
|
|
513
513
|
amount_tolerance: float = 0.02,
|
|
514
514
|
date_tolerance_days: int = 7,
|
|
515
|
-
merchant_normalizer:
|
|
516
|
-
variable_detector_llm:
|
|
517
|
-
insights_generator:
|
|
515
|
+
merchant_normalizer: MerchantNormalizer | None = None,
|
|
516
|
+
variable_detector_llm: VariableDetectorLLM | None = None,
|
|
517
|
+
insights_generator: SubscriptionInsightsGenerator | None = None,
|
|
518
518
|
):
|
|
519
519
|
"""
|
|
520
520
|
Initialize recurring detector.
|
|
@@ -14,7 +14,7 @@ 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,
|
|
17
|
+
from typing import Any, cast
|
|
18
18
|
|
|
19
19
|
from pydantic import BaseModel, ConfigDict, Field
|
|
20
20
|
|
|
@@ -41,14 +41,14 @@ class VariableRecurringPattern(BaseModel):
|
|
|
41
41
|
...,
|
|
42
42
|
description="True if pattern is recurring despite variance",
|
|
43
43
|
)
|
|
44
|
-
cadence:
|
|
44
|
+
cadence: str | None = Field(
|
|
45
45
|
None,
|
|
46
46
|
description=(
|
|
47
47
|
"Frequency if recurring: monthly, bi-weekly, quarterly, annual, etc. "
|
|
48
48
|
"None if not recurring."
|
|
49
49
|
),
|
|
50
50
|
)
|
|
51
|
-
expected_range:
|
|
51
|
+
expected_range: tuple[float, float] | None = Field(
|
|
52
52
|
None,
|
|
53
53
|
description=(
|
|
54
54
|
"Expected amount range (min, max) if recurring. "
|
|
@@ -100,7 +100,7 @@ Examples:
|
|
|
100
100
|
1. Merchant: "City Electric"
|
|
101
101
|
Amounts: [$45, $52, $48, $55, $50, $49]
|
|
102
102
|
Dates: Monthly (15th ±7 days)
|
|
103
|
-
→ is_recurring: true, cadence: "monthly", range: (40, 60),
|
|
103
|
+
→ is_recurring: true, cadence: "monthly", range: (40, 60),
|
|
104
104
|
reasoning: "Seasonal winter heating variation", confidence: 0.85
|
|
105
105
|
|
|
106
106
|
2. Merchant: "T-Mobile"
|
|
@@ -164,7 +164,7 @@ class VariableDetectorLLM:
|
|
|
164
164
|
def __init__(
|
|
165
165
|
self,
|
|
166
166
|
provider: str = "google",
|
|
167
|
-
model_name:
|
|
167
|
+
model_name: str | None = None,
|
|
168
168
|
max_cost_per_day: float = 0.10,
|
|
169
169
|
max_cost_per_month: float = 2.00,
|
|
170
170
|
):
|
|
@@ -293,7 +293,7 @@ class VariableDetectorLLM:
|
|
|
293
293
|
|
|
294
294
|
# Extract structured output
|
|
295
295
|
if hasattr(response, "structured") and response.structured:
|
|
296
|
-
return cast(VariableRecurringPattern, response.structured)
|
|
296
|
+
return cast("VariableRecurringPattern", response.structured)
|
|
297
297
|
else:
|
|
298
298
|
raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
|
|
299
299
|
|
fin_infra/recurring/ease.py
CHANGED
|
@@ -9,8 +9,6 @@ variable amount detection, and natural language insights.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from typing import Optional
|
|
13
|
-
|
|
14
12
|
from .detector import RecurringDetector
|
|
15
13
|
|
|
16
14
|
|
|
@@ -20,7 +18,7 @@ def easy_recurring_detection(
|
|
|
20
18
|
date_tolerance_days: int = 7,
|
|
21
19
|
enable_llm: bool = False,
|
|
22
20
|
llm_provider: str = "google",
|
|
23
|
-
llm_model:
|
|
21
|
+
llm_model: str | None = None,
|
|
24
22
|
llm_confidence_threshold: float = 0.8,
|
|
25
23
|
llm_cache_merchant_ttl: int = 604800, # 7 days
|
|
26
24
|
llm_cache_insights_ttl: int = 86400, # 24 hours
|
|
@@ -216,9 +214,9 @@ def easy_recurring_detection(
|
|
|
216
214
|
if enable_llm:
|
|
217
215
|
# Import V2 components only if needed (avoid circular imports)
|
|
218
216
|
try:
|
|
219
|
-
from .normalizers import MerchantNormalizer
|
|
220
217
|
from .detectors_llm import VariableDetectorLLM
|
|
221
218
|
from .insights import SubscriptionInsightsGenerator
|
|
219
|
+
from .normalizers import MerchantNormalizer
|
|
222
220
|
except ImportError as e:
|
|
223
221
|
raise ImportError(
|
|
224
222
|
f"LLM components not available. Install ai-infra: pip install ai-infra. Error: {e}"
|