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
fin_infra/brokerage/__init__.py
CHANGED
|
@@ -22,10 +22,12 @@ from typing import TYPE_CHECKING, Literal
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
23
|
from fastapi import FastAPI
|
|
24
24
|
|
|
25
|
-
from ..providers.base import BrokerageProvider
|
|
26
|
-
from pydantic import BaseModel, Field
|
|
27
25
|
from decimal import Decimal
|
|
28
26
|
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
from ..providers.base import BrokerageProvider
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
# Request model for order submission (used by add_brokerage FastAPI routes)
|
|
31
33
|
class OrderRequest(BaseModel):
|
|
@@ -123,11 +125,11 @@ def easy_brokerage(
|
|
|
123
125
|
)
|
|
124
126
|
|
|
125
127
|
else:
|
|
126
|
-
raise ValueError(f"Unknown brokerage provider: {provider_name}.
|
|
128
|
+
raise ValueError(f"Unknown brokerage provider: {provider_name}. Supported: alpaca")
|
|
127
129
|
|
|
128
130
|
|
|
129
131
|
def add_brokerage(
|
|
130
|
-
app:
|
|
132
|
+
app: FastAPI,
|
|
131
133
|
*,
|
|
132
134
|
provider: str | BrokerageProvider | None = None,
|
|
133
135
|
mode: Literal["paper", "live"] = "paper",
|
|
@@ -206,13 +208,15 @@ def add_brokerage(
|
|
|
206
208
|
>>> broker = add_brokerage(app, mode="live")
|
|
207
209
|
>>> # Only use in production with proper safeguards and risk management
|
|
208
210
|
"""
|
|
209
|
-
from svc_infra.api.fastapi.dual.public import public_router
|
|
210
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
211
211
|
from fastapi import HTTPException, Query
|
|
212
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
213
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
212
214
|
|
|
213
215
|
# Initialize provider if string or None
|
|
214
216
|
if isinstance(provider, str):
|
|
215
|
-
|
|
217
|
+
# Cast provider string to Literal type for type checker
|
|
218
|
+
provider_literal: Literal["alpaca"] | None = provider if provider == "alpaca" else None # type: ignore[assignment]
|
|
219
|
+
brokerage_provider = easy_brokerage(provider=provider_literal, mode=mode, **config)
|
|
216
220
|
elif provider is None:
|
|
217
221
|
brokerage_provider = easy_brokerage(mode=mode, **config)
|
|
218
222
|
else:
|
|
@@ -232,7 +236,7 @@ def add_brokerage(
|
|
|
232
236
|
account = brokerage_provider.get_account()
|
|
233
237
|
return account
|
|
234
238
|
except Exception as e:
|
|
235
|
-
raise HTTPException(status_code=500, detail=f"Error fetching account: {
|
|
239
|
+
raise HTTPException(status_code=500, detail=f"Error fetching account: {e!s}")
|
|
236
240
|
|
|
237
241
|
@router.get("/positions")
|
|
238
242
|
async def list_positions():
|
|
@@ -241,10 +245,10 @@ def add_brokerage(
|
|
|
241
245
|
Returns list of positions with symbol, quantity, P/L, etc.
|
|
242
246
|
"""
|
|
243
247
|
try:
|
|
244
|
-
positions = brokerage_provider.positions()
|
|
248
|
+
positions = list(brokerage_provider.positions()) # Convert Iterable to list for len()
|
|
245
249
|
return {"positions": positions, "count": len(positions)}
|
|
246
250
|
except Exception as e:
|
|
247
|
-
raise HTTPException(status_code=500, detail=f"Error fetching positions: {
|
|
251
|
+
raise HTTPException(status_code=500, detail=f"Error fetching positions: {e!s}")
|
|
248
252
|
|
|
249
253
|
@router.get("/positions/{symbol}")
|
|
250
254
|
async def get_position(symbol: str):
|
|
@@ -257,9 +261,7 @@ def add_brokerage(
|
|
|
257
261
|
position = brokerage_provider.get_position(symbol)
|
|
258
262
|
return position
|
|
259
263
|
except Exception as e:
|
|
260
|
-
raise HTTPException(
|
|
261
|
-
status_code=404, detail=f"Position not found for {symbol}: {str(e)}"
|
|
262
|
-
)
|
|
264
|
+
raise HTTPException(status_code=404, detail=f"Position not found for {symbol}: {e!s}")
|
|
263
265
|
|
|
264
266
|
@router.delete("/positions/{symbol}")
|
|
265
267
|
async def close_position(symbol: str):
|
|
@@ -272,7 +274,7 @@ def add_brokerage(
|
|
|
272
274
|
order = brokerage_provider.close_position(symbol)
|
|
273
275
|
return {"message": f"Closing position for {symbol}", "order": order}
|
|
274
276
|
except Exception as e:
|
|
275
|
-
raise HTTPException(status_code=400, detail=f"Error closing position: {
|
|
277
|
+
raise HTTPException(status_code=400, detail=f"Error closing position: {e!s}")
|
|
276
278
|
|
|
277
279
|
@router.post("/orders")
|
|
278
280
|
async def submit_order(order_request: OrderRequest):
|
|
@@ -293,7 +295,7 @@ def add_brokerage(
|
|
|
293
295
|
)
|
|
294
296
|
return order
|
|
295
297
|
except Exception as e:
|
|
296
|
-
raise HTTPException(status_code=400, detail=f"Error submitting order: {
|
|
298
|
+
raise HTTPException(status_code=400, detail=f"Error submitting order: {e!s}")
|
|
297
299
|
|
|
298
300
|
@router.get("/orders")
|
|
299
301
|
async def list_orders(
|
|
@@ -310,7 +312,7 @@ def add_brokerage(
|
|
|
310
312
|
orders = brokerage_provider.list_orders(status=status, limit=limit)
|
|
311
313
|
return {"orders": orders, "count": len(orders)}
|
|
312
314
|
except Exception as e:
|
|
313
|
-
raise HTTPException(status_code=500, detail=f"Error fetching orders: {
|
|
315
|
+
raise HTTPException(status_code=500, detail=f"Error fetching orders: {e!s}")
|
|
314
316
|
|
|
315
317
|
@router.get("/orders/{order_id}")
|
|
316
318
|
async def get_order(order_id: str):
|
|
@@ -323,7 +325,7 @@ def add_brokerage(
|
|
|
323
325
|
order = brokerage_provider.get_order(order_id)
|
|
324
326
|
return order
|
|
325
327
|
except Exception as e:
|
|
326
|
-
raise HTTPException(status_code=404, detail=f"Order not found: {
|
|
328
|
+
raise HTTPException(status_code=404, detail=f"Order not found: {e!s}")
|
|
327
329
|
|
|
328
330
|
@router.delete("/orders/{order_id}")
|
|
329
331
|
async def cancel_order(order_id: str):
|
|
@@ -336,7 +338,7 @@ def add_brokerage(
|
|
|
336
338
|
brokerage_provider.cancel_order(order_id)
|
|
337
339
|
return {"message": f"Order {order_id} canceled successfully"}
|
|
338
340
|
except Exception as e:
|
|
339
|
-
raise HTTPException(status_code=400, detail=f"Error canceling order: {
|
|
341
|
+
raise HTTPException(status_code=400, detail=f"Error canceling order: {e!s}")
|
|
340
342
|
|
|
341
343
|
@router.get("/portfolio/history")
|
|
342
344
|
async def get_portfolio_history(
|
|
@@ -353,9 +355,7 @@ def add_brokerage(
|
|
|
353
355
|
history = brokerage_provider.get_portfolio_history(period=period, timeframe=timeframe)
|
|
354
356
|
return history
|
|
355
357
|
except Exception as e:
|
|
356
|
-
raise HTTPException(
|
|
357
|
-
status_code=500, detail=f"Error fetching portfolio history: {str(e)}"
|
|
358
|
-
)
|
|
358
|
+
raise HTTPException(status_code=500, detail=f"Error fetching portfolio history: {e!s}")
|
|
359
359
|
|
|
360
360
|
# Watchlist routes
|
|
361
361
|
@router.post("/watchlists")
|
|
@@ -373,7 +373,7 @@ def add_brokerage(
|
|
|
373
373
|
watchlist = brokerage_provider.create_watchlist(name=name, symbols=symbols)
|
|
374
374
|
return watchlist
|
|
375
375
|
except Exception as e:
|
|
376
|
-
raise HTTPException(status_code=400, detail=f"Error creating watchlist: {
|
|
376
|
+
raise HTTPException(status_code=400, detail=f"Error creating watchlist: {e!s}")
|
|
377
377
|
|
|
378
378
|
@router.get("/watchlists")
|
|
379
379
|
async def list_watchlists():
|
|
@@ -382,7 +382,7 @@ def add_brokerage(
|
|
|
382
382
|
watchlists = brokerage_provider.list_watchlists()
|
|
383
383
|
return {"watchlists": watchlists, "count": len(watchlists)}
|
|
384
384
|
except Exception as e:
|
|
385
|
-
raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {
|
|
385
|
+
raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {e!s}")
|
|
386
386
|
|
|
387
387
|
@router.get("/watchlists/{watchlist_id}")
|
|
388
388
|
async def get_watchlist(watchlist_id: str):
|
|
@@ -395,7 +395,7 @@ def add_brokerage(
|
|
|
395
395
|
watchlist = brokerage_provider.get_watchlist(watchlist_id)
|
|
396
396
|
return watchlist
|
|
397
397
|
except Exception as e:
|
|
398
|
-
raise HTTPException(status_code=404, detail=f"Watchlist not found: {
|
|
398
|
+
raise HTTPException(status_code=404, detail=f"Watchlist not found: {e!s}")
|
|
399
399
|
|
|
400
400
|
@router.delete("/watchlists/{watchlist_id}")
|
|
401
401
|
async def delete_watchlist(watchlist_id: str):
|
|
@@ -408,7 +408,7 @@ def add_brokerage(
|
|
|
408
408
|
brokerage_provider.delete_watchlist(watchlist_id)
|
|
409
409
|
return {"message": f"Watchlist {watchlist_id} deleted successfully"}
|
|
410
410
|
except Exception as e:
|
|
411
|
-
raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {
|
|
411
|
+
raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {e!s}")
|
|
412
412
|
|
|
413
413
|
@router.post("/watchlists/{watchlist_id}/symbols")
|
|
414
414
|
async def add_to_watchlist(
|
|
@@ -424,7 +424,7 @@ def add_brokerage(
|
|
|
424
424
|
watchlist = brokerage_provider.add_to_watchlist(watchlist_id, symbol)
|
|
425
425
|
return watchlist
|
|
426
426
|
except Exception as e:
|
|
427
|
-
raise HTTPException(status_code=400, detail=f"Error adding symbol: {
|
|
427
|
+
raise HTTPException(status_code=400, detail=f"Error adding symbol: {e!s}")
|
|
428
428
|
|
|
429
429
|
@router.delete("/watchlists/{watchlist_id}/symbols/{symbol}")
|
|
430
430
|
async def remove_from_watchlist(watchlist_id: str, symbol: str):
|
|
@@ -438,7 +438,7 @@ def add_brokerage(
|
|
|
438
438
|
watchlist = brokerage_provider.remove_from_watchlist(watchlist_id, symbol)
|
|
439
439
|
return watchlist
|
|
440
440
|
except Exception as e:
|
|
441
|
-
raise HTTPException(status_code=400, detail=f"Error removing symbol: {
|
|
441
|
+
raise HTTPException(status_code=400, detail=f"Error removing symbol: {e!s}")
|
|
442
442
|
|
|
443
443
|
# Mount router
|
|
444
444
|
app.include_router(router, include_in_schema=True)
|
fin_infra/budgets/__init__.py
CHANGED
|
@@ -105,12 +105,12 @@ def __getattr__(name: str):
|
|
|
105
105
|
):
|
|
106
106
|
from fin_infra.budgets.models import ( # noqa: F401
|
|
107
107
|
Budget,
|
|
108
|
-
|
|
109
|
-
BudgetPeriod,
|
|
108
|
+
BudgetAlert,
|
|
110
109
|
BudgetCategory,
|
|
110
|
+
BudgetPeriod,
|
|
111
111
|
BudgetProgress,
|
|
112
|
-
BudgetAlert,
|
|
113
112
|
BudgetTemplate,
|
|
113
|
+
BudgetType,
|
|
114
114
|
)
|
|
115
115
|
|
|
116
116
|
return locals()[name]
|
fin_infra/budgets/add.py
CHANGED
|
@@ -22,7 +22,6 @@ Generic Design:
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
from datetime import datetime
|
|
25
|
-
from typing import Optional
|
|
26
25
|
|
|
27
26
|
from fastapi import FastAPI, HTTPException, Query
|
|
28
27
|
from pydantic import BaseModel, Field
|
|
@@ -42,16 +41,16 @@ class CreateBudgetRequest(BaseModel):
|
|
|
42
41
|
type: BudgetType = Field(..., description="Budget type")
|
|
43
42
|
period: BudgetPeriod = Field(..., description="Budget period")
|
|
44
43
|
categories: dict[str, float] = Field(..., description="Category allocations")
|
|
45
|
-
start_date:
|
|
44
|
+
start_date: datetime | None = Field(None, description="Start date (defaults to now)")
|
|
46
45
|
rollover_enabled: bool = Field(False, description="Enable rollover")
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
class UpdateBudgetRequest(BaseModel):
|
|
50
49
|
"""Request model for updating a budget."""
|
|
51
50
|
|
|
52
|
-
name:
|
|
53
|
-
categories:
|
|
54
|
-
rollover_enabled:
|
|
51
|
+
name: str | None = Field(None, description="Updated budget name")
|
|
52
|
+
categories: dict[str, float] | None = Field(None, description="Updated categories")
|
|
53
|
+
rollover_enabled: bool | None = Field(None, description="Updated rollover setting")
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
class ApplyTemplateRequest(BaseModel):
|
|
@@ -60,14 +59,14 @@ class ApplyTemplateRequest(BaseModel):
|
|
|
60
59
|
user_id: str = Field(..., description="User identifier")
|
|
61
60
|
template_name: str = Field(..., description="Template name (e.g., '50_30_20')")
|
|
62
61
|
total_income: float = Field(..., description="Total income/budget amount", gt=0)
|
|
63
|
-
budget_name:
|
|
64
|
-
start_date:
|
|
62
|
+
budget_name: str | None = Field(None, description="Optional budget name")
|
|
63
|
+
start_date: datetime | None = Field(None, description="Optional start date")
|
|
65
64
|
|
|
66
65
|
|
|
67
66
|
def add_budgets(
|
|
68
67
|
app: FastAPI,
|
|
69
|
-
tracker:
|
|
70
|
-
db_url:
|
|
68
|
+
tracker: BudgetTracker | None = None,
|
|
69
|
+
db_url: str | None = None,
|
|
71
70
|
prefix: str = "/budgets",
|
|
72
71
|
) -> BudgetTracker:
|
|
73
72
|
"""Add budget management endpoints to FastAPI app.
|
|
@@ -162,13 +161,13 @@ def add_budgets(
|
|
|
162
161
|
except ValueError as e:
|
|
163
162
|
raise HTTPException(status_code=400, detail=str(e))
|
|
164
163
|
except Exception as e:
|
|
165
|
-
raise HTTPException(status_code=500, detail=f"Failed to create budget: {
|
|
164
|
+
raise HTTPException(status_code=500, detail=f"Failed to create budget: {e!s}")
|
|
166
165
|
|
|
167
166
|
# Endpoint 2: List budgets
|
|
168
167
|
@router.get("", response_model=list[Budget], summary="List Budgets")
|
|
169
168
|
async def list_budgets(
|
|
170
169
|
user_id: str = Query(..., description="User identifier"),
|
|
171
|
-
type:
|
|
170
|
+
type: BudgetType | None = Query(None, description="Filter by budget type"),
|
|
172
171
|
) -> list[Budget]:
|
|
173
172
|
"""
|
|
174
173
|
List budgets for a user.
|
|
@@ -189,7 +188,7 @@ def add_budgets(
|
|
|
189
188
|
budgets = await tracker.get_budgets(user_id=user_id, type=type)
|
|
190
189
|
return budgets
|
|
191
190
|
except Exception as e:
|
|
192
|
-
raise HTTPException(status_code=500, detail=f"Failed to list budgets: {
|
|
191
|
+
raise HTTPException(status_code=500, detail=f"Failed to list budgets: {e!s}")
|
|
193
192
|
|
|
194
193
|
# Endpoint 3: Get single budget
|
|
195
194
|
@router.get("/{budget_id}", response_model=Budget, summary="Get Budget")
|
|
@@ -217,7 +216,7 @@ def add_budgets(
|
|
|
217
216
|
except ValueError as e:
|
|
218
217
|
raise HTTPException(status_code=404, detail=str(e))
|
|
219
218
|
except Exception as e:
|
|
220
|
-
raise HTTPException(status_code=500, detail=f"Failed to get budget: {
|
|
219
|
+
raise HTTPException(status_code=500, detail=f"Failed to get budget: {e!s}")
|
|
221
220
|
|
|
222
221
|
# Endpoint 4: Update budget
|
|
223
222
|
@router.patch("/{budget_id}", response_model=Budget, summary="Update Budget")
|
|
@@ -269,7 +268,7 @@ def add_budgets(
|
|
|
269
268
|
else:
|
|
270
269
|
raise HTTPException(status_code=400, detail=error_msg)
|
|
271
270
|
except Exception as e:
|
|
272
|
-
raise HTTPException(status_code=500, detail=f"Failed to update budget: {
|
|
271
|
+
raise HTTPException(status_code=500, detail=f"Failed to update budget: {e!s}")
|
|
273
272
|
|
|
274
273
|
# Endpoint 5: Delete budget
|
|
275
274
|
@router.delete("/{budget_id}", status_code=204, summary="Delete Budget", response_model=None)
|
|
@@ -296,7 +295,7 @@ def add_budgets(
|
|
|
296
295
|
except ValueError as e:
|
|
297
296
|
raise HTTPException(status_code=404, detail=str(e))
|
|
298
297
|
except Exception as e:
|
|
299
|
-
raise HTTPException(status_code=500, detail=f"Failed to delete budget: {
|
|
298
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete budget: {e!s}")
|
|
300
299
|
|
|
301
300
|
# Endpoint 6: Get budget progress
|
|
302
301
|
@router.get(
|
|
@@ -328,7 +327,7 @@ def add_budgets(
|
|
|
328
327
|
except ValueError as e:
|
|
329
328
|
raise HTTPException(status_code=404, detail=str(e))
|
|
330
329
|
except Exception as e:
|
|
331
|
-
raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {
|
|
330
|
+
raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {e!s}")
|
|
332
331
|
|
|
333
332
|
# Endpoint 7: List templates
|
|
334
333
|
@router.get("/templates/list", response_model=dict, summary="List Budget Templates")
|
|
@@ -402,7 +401,7 @@ def add_budgets(
|
|
|
402
401
|
raise HTTPException(status_code=400, detail=str(e))
|
|
403
402
|
except Exception as e:
|
|
404
403
|
raise HTTPException(
|
|
405
|
-
status_code=500, detail=f"Failed to create budget from template: {
|
|
404
|
+
status_code=500, detail=f"Failed to create budget from template: {e!s}"
|
|
406
405
|
)
|
|
407
406
|
|
|
408
407
|
# Mount router
|
fin_infra/budgets/alerts.py
CHANGED
|
@@ -35,7 +35,7 @@ Example:
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
37
|
from datetime import datetime
|
|
38
|
-
from typing import TYPE_CHECKING,
|
|
38
|
+
from typing import TYPE_CHECKING, Optional
|
|
39
39
|
|
|
40
40
|
from fin_infra.budgets.models import (
|
|
41
41
|
AlertSeverity,
|
|
@@ -52,7 +52,7 @@ async def check_budget_alerts(
|
|
|
52
52
|
budget_id: str,
|
|
53
53
|
tracker: BudgetTracker,
|
|
54
54
|
thresholds: Optional[dict[str, float]] = None,
|
|
55
|
-
) ->
|
|
55
|
+
) -> list[BudgetAlert]:
|
|
56
56
|
"""
|
|
57
57
|
Check budget for alerts (overspending, approaching limits, unusual patterns).
|
|
58
58
|
|
|
@@ -111,7 +111,7 @@ async def check_budget_alerts(
|
|
|
111
111
|
# Get budget progress
|
|
112
112
|
progress = await tracker.get_budget_progress(budget_id)
|
|
113
113
|
|
|
114
|
-
alerts:
|
|
114
|
+
alerts: list[BudgetAlert] = []
|
|
115
115
|
|
|
116
116
|
# Check each category for alerts
|
|
117
117
|
for category in progress.categories:
|
fin_infra/budgets/tracker.py
CHANGED
|
@@ -36,7 +36,7 @@ from __future__ import annotations
|
|
|
36
36
|
|
|
37
37
|
import uuid
|
|
38
38
|
from datetime import datetime, timedelta
|
|
39
|
-
from typing import TYPE_CHECKING,
|
|
39
|
+
from typing import TYPE_CHECKING, Optional
|
|
40
40
|
|
|
41
41
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
42
42
|
|
|
@@ -155,7 +155,7 @@ class BudgetTracker:
|
|
|
155
155
|
BudgetType(type)
|
|
156
156
|
except ValueError:
|
|
157
157
|
raise ValueError(
|
|
158
|
-
f"Invalid budget type: {type}.
|
|
158
|
+
f"Invalid budget type: {type}. Valid types: {[t.value for t in BudgetType]}"
|
|
159
159
|
)
|
|
160
160
|
|
|
161
161
|
# Validate budget period
|
|
@@ -163,8 +163,7 @@ class BudgetTracker:
|
|
|
163
163
|
BudgetPeriod(period)
|
|
164
164
|
except ValueError:
|
|
165
165
|
raise ValueError(
|
|
166
|
-
f"Invalid budget period: {period}. "
|
|
167
|
-
f"Valid periods: {[p.value for p in BudgetPeriod]}"
|
|
166
|
+
f"Invalid budget period: {period}. Valid periods: {[p.value for p in BudgetPeriod]}"
|
|
168
167
|
)
|
|
169
168
|
|
|
170
169
|
# Validate categories
|
|
@@ -207,7 +206,7 @@ class BudgetTracker:
|
|
|
207
206
|
self,
|
|
208
207
|
user_id: str,
|
|
209
208
|
type: Optional[str] = None,
|
|
210
|
-
) ->
|
|
209
|
+
) -> list[Budget]:
|
|
211
210
|
"""
|
|
212
211
|
Get all budgets for a user.
|
|
213
212
|
|
fin_infra/cashflows/__init__.py
CHANGED
|
@@ -22,11 +22,14 @@ Example usage:
|
|
|
22
22
|
rate = irr(cashflows)
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
from typing import
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
26
|
|
|
27
27
|
import numpy_financial as npf
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
|
|
32
|
+
from .core import irr, npv
|
|
30
33
|
|
|
31
34
|
__all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
|
|
32
35
|
|
|
@@ -110,7 +113,7 @@ def pv(rate: float, nper: int, pmt: float, fv: float = 0, when: str = "end") ->
|
|
|
110
113
|
|
|
111
114
|
|
|
112
115
|
def add_cashflows(
|
|
113
|
-
app: "FastAPI",
|
|
116
|
+
app: "FastAPI",
|
|
114
117
|
*,
|
|
115
118
|
prefix: str = "/cashflows",
|
|
116
119
|
) -> None:
|
|
@@ -169,16 +172,11 @@ def add_cashflows(
|
|
|
169
172
|
- Integrated with svc-infra observability
|
|
170
173
|
- Scoped docs at {prefix}/docs
|
|
171
174
|
"""
|
|
172
|
-
from typing import TYPE_CHECKING
|
|
173
|
-
|
|
174
|
-
if TYPE_CHECKING:
|
|
175
|
-
from fastapi import FastAPI
|
|
176
|
-
|
|
177
175
|
from pydantic import BaseModel, Field
|
|
176
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
178
177
|
|
|
179
178
|
# Import svc-infra public router (no auth - utility calculations)
|
|
180
179
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
181
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
182
180
|
|
|
183
181
|
# Request/Response models
|
|
184
182
|
class NPVRequest(BaseModel):
|
|
@@ -254,4 +252,4 @@ def add_cashflows(
|
|
|
254
252
|
# Mount router
|
|
255
253
|
app.include_router(router, include_in_schema=True)
|
|
256
254
|
|
|
257
|
-
print(
|
|
255
|
+
print("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
|
fin_infra/cashflows/core.py
CHANGED
|
@@ -44,7 +44,7 @@ from .taxonomy import Category, CategoryGroup, get_all_categories, get_category_
|
|
|
44
44
|
try:
|
|
45
45
|
from .llm_layer import LLMCategorizer
|
|
46
46
|
except ImportError:
|
|
47
|
-
LLMCategorizer = None
|
|
47
|
+
LLMCategorizer = None # type: ignore[assignment,misc]
|
|
48
48
|
|
|
49
49
|
__all__ = [
|
|
50
50
|
# Easy setup
|
fin_infra/categorization/add.py
CHANGED
|
@@ -6,10 +6,10 @@ Uses svc-infra dual routers for consistent behavior.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import time
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from fastapi import FastAPI, HTTPException
|
|
12
11
|
|
|
12
|
+
from . import rules
|
|
13
13
|
from .ease import easy_categorization
|
|
14
14
|
from .engine import CategorizationEngine
|
|
15
15
|
from .models import (
|
|
@@ -18,7 +18,6 @@ from .models import (
|
|
|
18
18
|
CategoryStats,
|
|
19
19
|
)
|
|
20
20
|
from .taxonomy import CategoryGroup, count_categories, get_all_categories
|
|
21
|
-
from . import rules
|
|
22
21
|
|
|
23
22
|
|
|
24
23
|
def add_categorization(
|
|
@@ -96,7 +95,8 @@ def add_categorization(
|
|
|
96
95
|
start_time = time.perf_counter()
|
|
97
96
|
|
|
98
97
|
try:
|
|
99
|
-
|
|
98
|
+
# Await the async categorize method
|
|
99
|
+
prediction = await engine.categorize(
|
|
100
100
|
merchant_name=request.merchant_name,
|
|
101
101
|
user_id=request.user_id,
|
|
102
102
|
include_alternatives=request.include_alternatives,
|
|
@@ -121,7 +121,7 @@ def add_categorization(
|
|
|
121
121
|
raise HTTPException(status_code=400, detail=str(e))
|
|
122
122
|
|
|
123
123
|
@router.get("/categories")
|
|
124
|
-
async def list_categories(group:
|
|
124
|
+
async def list_categories(group: CategoryGroup | None = None):
|
|
125
125
|
"""
|
|
126
126
|
List all available categories.
|
|
127
127
|
|
|
@@ -135,21 +135,19 @@ def add_categorization(
|
|
|
135
135
|
categories = get_all_categories()
|
|
136
136
|
|
|
137
137
|
# Return category metadata
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for cat in categories
|
|
152
|
-
]
|
|
138
|
+
result = []
|
|
139
|
+
for cat in categories:
|
|
140
|
+
meta = get_category_metadata(cat)
|
|
141
|
+
result.append(
|
|
142
|
+
{
|
|
143
|
+
"name": cat.value,
|
|
144
|
+
"group": meta.group.value if meta else None,
|
|
145
|
+
"display_name": meta.display_name if meta else cat.value,
|
|
146
|
+
"description": meta.description if meta else None,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return result
|
|
153
151
|
|
|
154
152
|
@router.get("/stats", response_model=CategoryStats)
|
|
155
153
|
async def get_stats():
|
fin_infra/categorization/ease.py
CHANGED
|
@@ -13,7 +13,7 @@ from .engine import CategorizationEngine
|
|
|
13
13
|
try:
|
|
14
14
|
from .llm_layer import LLMCategorizer
|
|
15
15
|
except ImportError:
|
|
16
|
-
LLMCategorizer = None
|
|
16
|
+
LLMCategorizer = None # type: ignore[assignment,misc]
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def easy_categorization(
|
|
@@ -113,7 +113,7 @@ def easy_categorization(
|
|
|
113
113
|
if enable_llm:
|
|
114
114
|
if LLMCategorizer is None:
|
|
115
115
|
raise ImportError(
|
|
116
|
-
"LLM support requires ai-infra package.
|
|
116
|
+
"LLM support requires ai-infra package. Install with: pip install ai-infra"
|
|
117
117
|
)
|
|
118
118
|
|
|
119
119
|
# Map provider names to ai-infra provider format
|
|
@@ -125,8 +125,7 @@ def easy_categorization(
|
|
|
125
125
|
ai_infra_provider = provider_map.get(llm_provider)
|
|
126
126
|
if not ai_infra_provider:
|
|
127
127
|
raise ValueError(
|
|
128
|
-
f"Unsupported LLM provider: {llm_provider}. "
|
|
129
|
-
f"Use 'google', 'openai', or 'anthropic'."
|
|
128
|
+
f"Unsupported LLM provider: {llm_provider}. Use 'google', 'openai', or 'anthropic'."
|
|
130
129
|
)
|
|
131
130
|
|
|
132
131
|
# Default models per provider
|
|
@@ -10,8 +10,8 @@ Hybrid categorization engine (exact → regex → ML → LLM).
|
|
|
10
10
|
Expected overall accuracy: 95-97% (V2 with LLM)
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
import time
|
|
14
13
|
import logging
|
|
14
|
+
import time
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Optional
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ from .taxonomy import Category
|
|
|
23
23
|
try:
|
|
24
24
|
from .llm_layer import LLMCategorizer
|
|
25
25
|
except ImportError:
|
|
26
|
-
LLMCategorizer = None
|
|
26
|
+
LLMCategorizer = None # type: ignore[assignment,misc]
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
@@ -51,7 +51,7 @@ class CategorizationEngine:
|
|
|
51
51
|
enable_ml: bool = False,
|
|
52
52
|
enable_llm: bool = False,
|
|
53
53
|
confidence_threshold: float = 0.6,
|
|
54
|
-
model_path:
|
|
54
|
+
model_path: Path | None = None,
|
|
55
55
|
llm_categorizer: Optional["LLMCategorizer"] = None,
|
|
56
56
|
):
|
|
57
57
|
self.enable_ml = enable_ml
|
|
@@ -81,7 +81,7 @@ class CategorizationEngine:
|
|
|
81
81
|
async def categorize(
|
|
82
82
|
self,
|
|
83
83
|
merchant_name: str,
|
|
84
|
-
user_id:
|
|
84
|
+
user_id: str | None = None,
|
|
85
85
|
include_alternatives: bool = False,
|
|
86
86
|
) -> CategoryPrediction:
|
|
87
87
|
"""
|
|
@@ -95,7 +95,7 @@ class CategorizationEngine:
|
|
|
95
95
|
Returns:
|
|
96
96
|
CategoryPrediction with category, confidence, and method
|
|
97
97
|
"""
|
|
98
|
-
|
|
98
|
+
time.perf_counter()
|
|
99
99
|
|
|
100
100
|
# Normalize merchant name
|
|
101
101
|
normalized = self._normalize(merchant_name)
|
|
@@ -195,7 +195,7 @@ class CategorizationEngine:
|
|
|
195
195
|
|
|
196
196
|
def _predict_ml(
|
|
197
197
|
self, merchant_name: str, include_alternatives: bool = False
|
|
198
|
-
) ->
|
|
198
|
+
) -> CategoryPrediction | None:
|
|
199
199
|
"""
|
|
200
200
|
Predict category using ML model.
|
|
201
201
|
|
|
@@ -251,8 +251,7 @@ class CategorizationEngine:
|
|
|
251
251
|
)
|
|
252
252
|
|
|
253
253
|
except Exception as e:
|
|
254
|
-
|
|
255
|
-
print(f"ML prediction error: {e}")
|
|
254
|
+
logger.error("ML prediction error: %s", e)
|
|
256
255
|
return None
|
|
257
256
|
|
|
258
257
|
def _load_ml_model(self) -> None:
|
|
@@ -273,8 +272,10 @@ class CategorizationEngine:
|
|
|
273
272
|
vectorizer_file = self.model_path / "vectorizer.joblib"
|
|
274
273
|
|
|
275
274
|
if not model_file.exists() or not vectorizer_file.exists():
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
logger.warning(
|
|
276
|
+
"ML model not found at %s. Run training script to generate model files.",
|
|
277
|
+
self.model_path,
|
|
278
|
+
)
|
|
278
279
|
return
|
|
279
280
|
|
|
280
281
|
try:
|
|
@@ -282,12 +283,14 @@ class CategorizationEngine:
|
|
|
282
283
|
|
|
283
284
|
self._ml_model = joblib.load(model_file)
|
|
284
285
|
self._ml_vectorizer = joblib.load(vectorizer_file)
|
|
285
|
-
|
|
286
|
+
logger.info("Loaded ML model from %s", self.model_path)
|
|
286
287
|
except ImportError:
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
logger.warning(
|
|
289
|
+
"scikit-learn not installed. ML predictions disabled. "
|
|
290
|
+
"Install with: pip install scikit-learn"
|
|
291
|
+
)
|
|
289
292
|
except Exception as e:
|
|
290
|
-
|
|
293
|
+
logger.error("Error loading ML model: %s", e)
|
|
291
294
|
|
|
292
295
|
def add_rule(
|
|
293
296
|
self,
|
|
@@ -322,7 +325,7 @@ class CategorizationEngine:
|
|
|
322
325
|
|
|
323
326
|
|
|
324
327
|
# Singleton instance (for easy access)
|
|
325
|
-
_default_engine:
|
|
328
|
+
_default_engine: CategorizationEngine | None = None
|
|
326
329
|
|
|
327
330
|
|
|
328
331
|
def get_engine() -> CategorizationEngine:
|
|
@@ -333,9 +336,9 @@ def get_engine() -> CategorizationEngine:
|
|
|
333
336
|
return _default_engine
|
|
334
337
|
|
|
335
338
|
|
|
336
|
-
def categorize(
|
|
339
|
+
async def categorize(
|
|
337
340
|
merchant_name: str,
|
|
338
|
-
user_id:
|
|
341
|
+
user_id: str | None = None,
|
|
339
342
|
include_alternatives: bool = False,
|
|
340
343
|
) -> CategoryPrediction:
|
|
341
344
|
"""
|
|
@@ -350,4 +353,4 @@ def categorize(
|
|
|
350
353
|
CategoryPrediction with category, confidence, and method
|
|
351
354
|
"""
|
|
352
355
|
engine = get_engine()
|
|
353
|
-
return engine.categorize(merchant_name, user_id, include_alternatives)
|
|
356
|
+
return await engine.categorize(merchant_name, user_id, include_alternatives)
|