fin-infra 0.1.69__py3-none-any.whl → 0.4.0__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 +24 -24
- fin_infra/analytics/cash_flow.py +3 -3
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/models.py +5 -5
- fin_infra/analytics/portfolio.py +18 -18
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/spending.py +4 -5
- fin_infra/banking/__init__.py +27 -28
- fin_infra/banking/history.py +12 -13
- fin_infra/banking/utils.py +27 -26
- fin_infra/brokerage/__init__.py +29 -31
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +4 -4
- fin_infra/budgets/ease.py +1 -2
- fin_infra/budgets/models.py +1 -2
- fin_infra/budgets/templates.py +4 -4
- fin_infra/budgets/tracker.py +4 -4
- fin_infra/cashflows/__init__.py +3 -3
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/ease.py +3 -3
- fin_infra/categorization/engine.py +18 -15
- fin_infra/categorization/llm_layer.py +13 -10
- fin_infra/categorization/models.py +3 -4
- 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 +1 -2
- fin_infra/cli/cmds/scaffold_cmds.py +16 -17
- 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/parser.py +5 -5
- fin_infra/credit/experian/provider.py +4 -4
- fin_infra/crypto/__init__.py +9 -11
- 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 +5 -6
- 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 +5 -6
- fin_infra/goals/milestones.py +7 -8
- fin_infra/goals/models.py +9 -13
- fin_infra/insights/__init__.py +6 -3
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +3 -3
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/models.py +27 -29
- fin_infra/investments/providers/base.py +12 -13
- fin_infra/investments/providers/plaid.py +52 -26
- fin_infra/investments/providers/snaptrade.py +19 -19
- fin_infra/investments/scaffold_templates/README.md +17 -17
- fin_infra/markets/__init__.py +7 -5
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +4 -5
- 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 -5
- fin_infra/net_worth/__init__.py +8 -1
- fin_infra/net_worth/aggregator.py +5 -3
- fin_infra/net_worth/calculator.py +1 -1
- fin_infra/net_worth/insights.py +7 -8
- fin_infra/normalization/__init__.py +4 -4
- fin_infra/normalization/currency_converter.py +7 -8
- fin_infra/normalization/models.py +9 -10
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/normalization/providers/static_mappings.py +1 -1
- fin_infra/normalization/symbol_resolver.py +3 -4
- fin_infra/obs/classifier.py +3 -3
- 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 +4 -4
- 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 +6 -5
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +10 -10
- fin_infra/recurring/ease.py +6 -8
- fin_infra/recurring/insights.py +25 -24
- fin_infra/recurring/normalizer.py +7 -7
- fin_infra/recurring/normalizers.py +31 -30
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +9 -9
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/add.py +1 -2
- fin_infra/security/audit.py +6 -7
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +16 -16
- fin_infra/security/token_store.py +2 -3
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +5 -4
- fin_infra/tax/tlh.py +10 -10
- fin_infra/utils/__init__.py +15 -1
- fin_infra/utils/deprecation.py +161 -0
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
- fin_infra-0.4.0.dist-info/RECORD +181 -0
- fin_infra-0.1.69.dist-info/RECORD +0 -180
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -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):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Alpaca brokerage provider for paper and live trading.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[!] IMPORTANT: This module provides real trading capabilities. Always use paper trading
|
|
4
4
|
mode for development and testing. Live trading requires explicit opt-in.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -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
|
|
|
@@ -108,7 +109,7 @@ def add_recurring_detection(
|
|
|
108
109
|
Detect recurring patterns in transaction history.
|
|
109
110
|
|
|
110
111
|
Analyzes transaction history for recurring subscriptions and bills using
|
|
111
|
-
3-layer hybrid detection (fixed
|
|
112
|
+
3-layer hybrid detection (fixed -> variable -> irregular).
|
|
112
113
|
|
|
113
114
|
**Example Request:**
|
|
114
115
|
```json
|
|
@@ -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,30 +100,30 @@ Examples:
|
|
|
100
100
|
1. Merchant: "City Electric"
|
|
101
101
|
Amounts: [$45, $52, $48, $55, $50, $49]
|
|
102
102
|
Dates: Monthly (15th ±7 days)
|
|
103
|
-
|
|
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"
|
|
107
107
|
Amounts: [$50, $50, $50, $78, $50, $50]
|
|
108
108
|
Dates: Monthly (20th ±3 days)
|
|
109
|
-
|
|
109
|
+
-> is_recurring: true, cadence: "monthly", range: (50, 80),
|
|
110
110
|
reasoning: "Occasional overage charge spike", confidence: 0.80
|
|
111
111
|
|
|
112
112
|
3. Merchant: "Random Store"
|
|
113
113
|
Amounts: [$10, $45, $23, $67, $12]
|
|
114
114
|
Dates: Irregular
|
|
115
|
-
|
|
115
|
+
-> is_recurring: false, reasoning: "Too much variance, no pattern", confidence: 0.95
|
|
116
116
|
|
|
117
117
|
4. Merchant: "Gas Company"
|
|
118
118
|
Amounts: [$45, $48, $50, $52, $120, $115]
|
|
119
119
|
Dates: Monthly
|
|
120
|
-
|
|
120
|
+
-> is_recurring: true, cadence: "monthly", range: (40, 120),
|
|
121
121
|
reasoning: "Winter heating season doubles bill", confidence: 0.80
|
|
122
122
|
|
|
123
123
|
5. Merchant: "Gym Membership"
|
|
124
124
|
Amounts: [$40, $40, $0, $40, $40]
|
|
125
125
|
Dates: Monthly
|
|
126
|
-
|
|
126
|
+
-> is_recurring: true, cadence: "monthly", range: (0, 40),
|
|
127
127
|
reasoning: "Annual fee waived one month", confidence: 0.75
|
|
128
128
|
|
|
129
129
|
Output format (JSON):
|
|
@@ -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
|
|
@@ -45,7 +43,7 @@ def easy_recurring_detection(
|
|
|
45
43
|
V2 Parameters (LLM Enhancement):
|
|
46
44
|
enable_llm: Enable LLM for merchant normalization and variable detection (default: False)
|
|
47
45
|
When False, uses V1 pattern-based only (fast, $0 cost)
|
|
48
|
-
When True, uses 4-layer hybrid (RapidFuzz
|
|
46
|
+
When True, uses 4-layer hybrid (RapidFuzz -> LLM normalization -> Statistical -> LLM variable detection)
|
|
49
47
|
llm_provider: LLM provider to use (default: "google")
|
|
50
48
|
Options: "google" (Gemini 2.0 Flash, cheapest), "openai" (GPT-4o-mini), "anthropic" (Claude 3.5 Haiku)
|
|
51
49
|
llm_model: Override default model for provider (default: None)
|
|
@@ -56,9 +54,9 @@ def easy_recurring_detection(
|
|
|
56
54
|
Higher values (0.9) call LLM more often (more accurate, higher cost)
|
|
57
55
|
Lower values (0.7) call LLM less often (less accurate, lower cost)
|
|
58
56
|
llm_cache_merchant_ttl: Merchant normalization cache TTL in seconds (default: 604800 = 7 days)
|
|
59
|
-
95% cache hit rate expected
|
|
57
|
+
95% cache hit rate expected -> most requests <1ms
|
|
60
58
|
llm_cache_insights_ttl: Insights generation cache TTL in seconds (default: 86400 = 24 hours)
|
|
61
|
-
80% cache hit rate expected
|
|
59
|
+
80% cache hit rate expected -> most requests <1ms
|
|
62
60
|
llm_max_cost_per_day: Daily budget cap in USD (default: $0.10)
|
|
63
61
|
Supports ~33k normalizations or ~1k variable detections per day
|
|
64
62
|
Sufficient for 100k+ users
|
|
@@ -82,7 +80,7 @@ def easy_recurring_detection(
|
|
|
82
80
|
>>> # V2: LLM-enhanced detection (better accuracy, minimal cost)
|
|
83
81
|
>>> detector = easy_recurring_detection(enable_llm=True)
|
|
84
82
|
>>> patterns = detector.detect_patterns(transactions)
|
|
85
|
-
>>> # Merchant normalization: "NFLX*SUB"
|
|
83
|
+
>>> # Merchant normalization: "NFLX*SUB" -> "Netflix" (90-95% accuracy)
|
|
86
84
|
>>> # Variable detection: Utility bills with seasonal variance (85-88% accuracy)
|
|
87
85
|
>>> # Cost: ~$0.003/user/year with caching
|
|
88
86
|
|
|
@@ -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}"
|