fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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/analytics/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- 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 +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/add.py
CHANGED
|
@@ -208,12 +208,10 @@ def add_analytics(
|
|
|
208
208
|
user_id: str,
|
|
209
209
|
accounts: Optional[list[str]] = None,
|
|
210
210
|
with_holdings: bool = Query(
|
|
211
|
-
False,
|
|
212
|
-
description="Use real holdings data from investment provider for accurate P/L"
|
|
211
|
+
False, description="Use real holdings data from investment provider for accurate P/L"
|
|
213
212
|
),
|
|
214
213
|
access_token: Optional[str] = Query(
|
|
215
|
-
None,
|
|
216
|
-
description="Investment provider access token (required if with_holdings=true)"
|
|
214
|
+
None, description="Investment provider access token (required if with_holdings=true)"
|
|
217
215
|
),
|
|
218
216
|
) -> PortfolioMetrics:
|
|
219
217
|
"""
|
|
@@ -249,31 +247,31 @@ def add_analytics(
|
|
|
249
247
|
if with_holdings:
|
|
250
248
|
# Check if investment provider is available on app state
|
|
251
249
|
investment_provider = getattr(app.state, "investment_provider", None)
|
|
252
|
-
|
|
250
|
+
|
|
253
251
|
if investment_provider and access_token:
|
|
254
252
|
try:
|
|
255
253
|
# Fetch real holdings from investment provider
|
|
256
254
|
from fin_infra.analytics.portfolio import portfolio_metrics_with_holdings
|
|
257
|
-
|
|
255
|
+
|
|
258
256
|
holdings = await investment_provider.get_holdings(
|
|
259
257
|
access_token=access_token,
|
|
260
258
|
account_ids=accounts,
|
|
261
259
|
)
|
|
262
|
-
|
|
260
|
+
|
|
263
261
|
# Calculate metrics from real holdings
|
|
264
262
|
return portfolio_metrics_with_holdings(holdings)
|
|
265
|
-
|
|
263
|
+
|
|
266
264
|
except Exception as e:
|
|
267
265
|
# Fall back to balance-only calculation on error
|
|
268
266
|
# Log error but don't fail the request
|
|
269
267
|
import logging
|
|
268
|
+
|
|
270
269
|
logging.warning(f"Failed to fetch holdings, falling back to balance-only: {e}")
|
|
271
270
|
elif with_holdings and not access_token:
|
|
272
271
|
raise HTTPException(
|
|
273
|
-
status_code=400,
|
|
274
|
-
detail="access_token required when with_holdings=true"
|
|
272
|
+
status_code=400, detail="access_token required when with_holdings=true"
|
|
275
273
|
)
|
|
276
|
-
|
|
274
|
+
|
|
277
275
|
# Default: Use balance-only calculation (existing behavior)
|
|
278
276
|
return await provider.portfolio_metrics(
|
|
279
277
|
user_id,
|
fin_infra/analytics/cash_flow.py
CHANGED
|
@@ -6,6 +6,7 @@ Provides income vs expense analysis, breakdowns by source/category, and forecast
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
|
+
from decimal import Decimal
|
|
9
10
|
from typing import Any, Optional
|
|
10
11
|
|
|
11
12
|
from ..models import Transaction
|
|
@@ -219,7 +220,7 @@ async def forecast_cash_flow(
|
|
|
219
220
|
def _categorize_transactions(
|
|
220
221
|
transactions: list[Transaction],
|
|
221
222
|
categorization_provider=None,
|
|
222
|
-
) -> tuple[dict[str,
|
|
223
|
+
) -> tuple[dict[str, Decimal], dict[str, Decimal]]:
|
|
223
224
|
"""Helper to categorize transactions into income sources and expense categories.
|
|
224
225
|
|
|
225
226
|
Args:
|
|
@@ -229,19 +230,19 @@ def _categorize_transactions(
|
|
|
229
230
|
Returns:
|
|
230
231
|
Tuple of (income_by_source, expenses_by_category) dicts
|
|
231
232
|
"""
|
|
232
|
-
income_by_source: dict[str,
|
|
233
|
-
expenses_by_category: dict[str,
|
|
233
|
+
income_by_source: dict[str, Decimal] = {}
|
|
234
|
+
expenses_by_category: dict[str, Decimal] = {}
|
|
234
235
|
|
|
235
236
|
for txn in transactions:
|
|
236
237
|
if txn.amount > 0:
|
|
237
238
|
# Income transaction
|
|
238
239
|
source = _determine_income_source(txn)
|
|
239
|
-
income_by_source[source] = income_by_source.get(source, 0) + txn.amount
|
|
240
|
+
income_by_source[source] = income_by_source.get(source, Decimal(0)) + txn.amount
|
|
240
241
|
else:
|
|
241
242
|
# Expense transaction
|
|
242
243
|
category = _get_expense_category(txn, categorization_provider)
|
|
243
244
|
amount = abs(txn.amount)
|
|
244
|
-
expenses_by_category[category] = expenses_by_category.get(category, 0) + amount
|
|
245
|
+
expenses_by_category[category] = expenses_by_category.get(category, Decimal(0)) + amount
|
|
245
246
|
|
|
246
247
|
return income_by_source, expenses_by_category
|
|
247
248
|
|
fin_infra/analytics/portfolio.py
CHANGED
|
@@ -565,25 +565,18 @@ def portfolio_metrics_with_holdings(holdings: list) -> PortfolioMetrics:
|
|
|
565
565
|
>>> metrics = portfolio_metrics_with_holdings(holdings)
|
|
566
566
|
"""
|
|
567
567
|
# Import here to avoid circular dependency
|
|
568
|
-
from decimal import Decimal
|
|
569
568
|
|
|
570
569
|
# Calculate total portfolio value and cost basis
|
|
571
|
-
total_value = float(sum(
|
|
572
|
-
holding.institution_value
|
|
573
|
-
for holding in holdings
|
|
574
|
-
))
|
|
570
|
+
total_value = float(sum(holding.institution_value for holding in holdings))
|
|
575
571
|
|
|
576
|
-
total_cost_basis = float(
|
|
577
|
-
holding.cost_basis if holding.cost_basis is not None else 0
|
|
578
|
-
|
|
579
|
-
))
|
|
572
|
+
total_cost_basis = float(
|
|
573
|
+
sum(holding.cost_basis if holding.cost_basis is not None else 0 for holding in holdings)
|
|
574
|
+
)
|
|
580
575
|
|
|
581
576
|
# Calculate total return (P/L)
|
|
582
577
|
total_return_dollars = total_value - total_cost_basis
|
|
583
578
|
total_return_percent = (
|
|
584
|
-
(total_return_dollars / total_cost_basis * 100.0)
|
|
585
|
-
if total_cost_basis > 0
|
|
586
|
-
else 0.0
|
|
579
|
+
(total_return_dollars / total_cost_basis * 100.0) if total_cost_basis > 0 else 0.0
|
|
587
580
|
)
|
|
588
581
|
|
|
589
582
|
# Calculate asset allocation from real security types
|
|
@@ -685,9 +678,7 @@ def calculate_day_change_with_snapshot(
|
|
|
685
678
|
# Calculate day change
|
|
686
679
|
day_change_dollars = current_total - previous_total
|
|
687
680
|
day_change_percent = (
|
|
688
|
-
(day_change_dollars / previous_total * 100.0)
|
|
689
|
-
if previous_total > 0
|
|
690
|
-
else 0.0
|
|
681
|
+
(day_change_dollars / previous_total * 100.0) if previous_total > 0 else 0.0
|
|
691
682
|
)
|
|
692
683
|
|
|
693
684
|
return {
|
|
@@ -739,18 +730,20 @@ def _calculate_allocation_from_holdings(
|
|
|
739
730
|
}
|
|
740
731
|
|
|
741
732
|
# Sum values by asset class
|
|
742
|
-
allocation_values = defaultdict(float)
|
|
733
|
+
allocation_values: dict[str, float] = defaultdict(float)
|
|
743
734
|
for holding in holdings:
|
|
744
|
-
security_type =
|
|
735
|
+
security_type = (
|
|
736
|
+
holding.security.type.value
|
|
737
|
+
if hasattr(holding.security.type, "value")
|
|
738
|
+
else holding.security.type
|
|
739
|
+
)
|
|
745
740
|
asset_class = type_to_class.get(security_type, "Other")
|
|
746
741
|
allocation_values[asset_class] += float(holding.institution_value)
|
|
747
742
|
|
|
748
743
|
# Convert to list of AssetAllocation objects
|
|
749
744
|
allocation_list = [
|
|
750
745
|
AssetAllocation(
|
|
751
|
-
asset_class=asset_class,
|
|
752
|
-
value=value,
|
|
753
|
-
percentage=round((value / total_value) * 100.0, 2)
|
|
746
|
+
asset_class=asset_class, value=value, percentage=round((value / total_value) * 100.0, 2)
|
|
754
747
|
)
|
|
755
748
|
for asset_class, value in allocation_values.items()
|
|
756
749
|
]
|
|
@@ -329,13 +329,11 @@ def _generate_trade_reasoning(
|
|
|
329
329
|
|
|
330
330
|
if action == "buy":
|
|
331
331
|
return (
|
|
332
|
-
f"Buy {symbol} to increase {asset_class} allocation "
|
|
333
|
-
f"by {diff_pct:.1f}% towards target"
|
|
332
|
+
f"Buy {symbol} to increase {asset_class} allocation by {diff_pct:.1f}% towards target"
|
|
334
333
|
)
|
|
335
334
|
else:
|
|
336
335
|
return (
|
|
337
|
-
f"Sell {symbol} to decrease {asset_class} allocation "
|
|
338
|
-
f"by {diff_pct:.1f}% towards target"
|
|
336
|
+
f"Sell {symbol} to decrease {asset_class} allocation by {diff_pct:.1f}% towards target"
|
|
339
337
|
)
|
|
340
338
|
|
|
341
339
|
|
fin_infra/analytics/savings.py
CHANGED
|
@@ -77,7 +77,7 @@ async def calculate_savings_rate(
|
|
|
77
77
|
period_enum = Period(period)
|
|
78
78
|
except ValueError:
|
|
79
79
|
raise ValueError(
|
|
80
|
-
f"Invalid period '{period}'. Must be one of:
|
|
80
|
+
f"Invalid period '{period}'. Must be one of: {', '.join([p.value for p in Period])}"
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
try:
|
fin_infra/analytics/spending.py
CHANGED
|
@@ -162,12 +162,13 @@ async def analyze_spending(
|
|
|
162
162
|
)
|
|
163
163
|
|
|
164
164
|
return SpendingInsight(
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
# Convert Decimal to float for model compatibility (intentional for Pydantic field types)
|
|
166
|
+
top_merchants=[(m, float(v)) for m, v in top_merchants],
|
|
167
|
+
category_breakdown={k: float(v) for k, v in category_totals.items()},
|
|
167
168
|
spending_trends=spending_trends,
|
|
168
169
|
anomalies=anomalies,
|
|
169
170
|
period_days=days,
|
|
170
|
-
total_spending=total_spending,
|
|
171
|
+
total_spending=float(total_spending) if total_spending else 0.0,
|
|
171
172
|
)
|
|
172
173
|
|
|
173
174
|
|
|
@@ -291,10 +292,10 @@ async def _calculate_spending_trends(
|
|
|
291
292
|
# In reality, would fetch historical data
|
|
292
293
|
previous_amount = current_amount * Decimal("0.9")
|
|
293
294
|
|
|
294
|
-
change_percent = (
|
|
295
|
-
((current_amount - previous_amount) / previous_amount) * 100
|
|
295
|
+
change_percent: float = (
|
|
296
|
+
float((current_amount - previous_amount) / previous_amount) * 100
|
|
296
297
|
if previous_amount > 0
|
|
297
|
-
else 0
|
|
298
|
+
else 0.0
|
|
298
299
|
)
|
|
299
300
|
|
|
300
301
|
# Threshold for "stable" is within 5%
|
|
@@ -342,8 +343,10 @@ async def _detect_spending_anomalies(
|
|
|
342
343
|
# In reality, would calculate from historical data
|
|
343
344
|
average_amount = current_amount * Decimal("0.8")
|
|
344
345
|
|
|
345
|
-
deviation_percent = (
|
|
346
|
-
((current_amount - average_amount) / average_amount) * 100
|
|
346
|
+
deviation_percent: float = (
|
|
347
|
+
float((current_amount - average_amount) / average_amount) * 100
|
|
348
|
+
if average_amount > 0
|
|
349
|
+
else 0.0
|
|
347
350
|
)
|
|
348
351
|
|
|
349
352
|
# Detect anomalies based on deviation
|
|
@@ -359,9 +362,10 @@ async def _detect_spending_anomalies(
|
|
|
359
362
|
anomalies.append(
|
|
360
363
|
SpendingAnomaly(
|
|
361
364
|
category=category,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
+
# Convert Decimal to float for model compatibility (intentional for Pydantic field types)
|
|
366
|
+
current_amount=float(current_amount),
|
|
367
|
+
average_amount=float(average_amount),
|
|
368
|
+
deviation_percent=float(deviation_percent),
|
|
365
369
|
severity=severity,
|
|
366
370
|
)
|
|
367
371
|
)
|
fin_infra/banking/__init__.py
CHANGED
|
@@ -45,7 +45,7 @@ from __future__ import annotations
|
|
|
45
45
|
|
|
46
46
|
import os
|
|
47
47
|
from datetime import date
|
|
48
|
-
from typing import TYPE_CHECKING, Optional
|
|
48
|
+
from typing import TYPE_CHECKING, Optional, cast
|
|
49
49
|
|
|
50
50
|
from pydantic import BaseModel, Field
|
|
51
51
|
|
|
@@ -199,7 +199,7 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
# Use provider registry to dynamically load and configure provider
|
|
202
|
-
return resolve("banking", provider, **config)
|
|
202
|
+
return cast(BankingProvider, resolve("banking", provider, **config))
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
def add_banking(
|
|
@@ -397,10 +397,13 @@ def add_banking(
|
|
|
397
397
|
}
|
|
398
398
|
"""
|
|
399
399
|
# Get all transactions from provider
|
|
400
|
+
# Convert date to ISO string format as expected by BankingProvider.transactions()
|
|
401
|
+
start_date_str: str | None = start_date.isoformat() if start_date else None
|
|
402
|
+
end_date_str: str | None = end_date.isoformat() if end_date else None
|
|
400
403
|
transactions = banking.transactions(
|
|
401
404
|
access_token=access_token,
|
|
402
|
-
start_date=
|
|
403
|
-
end_date=
|
|
405
|
+
start_date=start_date_str,
|
|
406
|
+
end_date=end_date_str,
|
|
404
407
|
)
|
|
405
408
|
|
|
406
409
|
# Apply filters
|
|
@@ -589,7 +592,7 @@ def add_banking(
|
|
|
589
592
|
|
|
590
593
|
|
|
591
594
|
# Import utilities at end to avoid circular imports
|
|
592
|
-
from .utils import (
|
|
595
|
+
from .utils import ( # noqa: E402
|
|
593
596
|
validate_plaid_token,
|
|
594
597
|
validate_teller_token,
|
|
595
598
|
validate_mx_token,
|
fin_infra/banking/history.py
CHANGED
|
@@ -68,10 +68,10 @@ def _check_in_memory_warning() -> None:
|
|
|
68
68
|
global _production_warning_logged
|
|
69
69
|
if _production_warning_logged:
|
|
70
70
|
return
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
env = os.getenv("ENV", "development").lower()
|
|
73
73
|
storage_backend = os.getenv("FIN_INFRA_STORAGE_BACKEND", "memory").lower()
|
|
74
|
-
|
|
74
|
+
|
|
75
75
|
if env in ("production", "staging") and storage_backend == "memory":
|
|
76
76
|
_logger.warning(
|
|
77
77
|
"⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
|
|
@@ -135,7 +135,7 @@ def record_balance_snapshot(
|
|
|
135
135
|
"""
|
|
136
136
|
# Check if in-memory storage is being used in production
|
|
137
137
|
_check_in_memory_warning()
|
|
138
|
-
|
|
138
|
+
|
|
139
139
|
snapshot = BalanceSnapshot(
|
|
140
140
|
account_id=account_id,
|
|
141
141
|
balance=balance,
|