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
fin_infra/banking/utils.py
CHANGED
|
@@ -8,8 +8,9 @@ Apps still manage user-to-token mappings, but these utilities simplify common op
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
|
-
from datetime import
|
|
12
|
-
from typing import Any,
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
13
14
|
from pydantic import BaseModel, ConfigDict, Field
|
|
14
15
|
|
|
15
16
|
from ..providers.base import BankingProvider
|
|
@@ -22,23 +23,23 @@ class BankingConnectionInfo(BaseModel):
|
|
|
22
23
|
|
|
23
24
|
provider: Literal["plaid", "teller", "mx"]
|
|
24
25
|
connected: bool
|
|
25
|
-
access_token:
|
|
26
|
+
access_token: str | None = Field(
|
|
26
27
|
None, description="Token (only for internal use, never expose)"
|
|
27
28
|
)
|
|
28
|
-
item_id:
|
|
29
|
-
enrollment_id:
|
|
30
|
-
connected_at:
|
|
31
|
-
last_synced_at:
|
|
29
|
+
item_id: str | None = None
|
|
30
|
+
enrollment_id: str | None = None
|
|
31
|
+
connected_at: datetime | None = None
|
|
32
|
+
last_synced_at: datetime | None = None
|
|
32
33
|
is_healthy: bool = True
|
|
33
|
-
error_message:
|
|
34
|
+
error_message: str | None = None
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class BankingConnectionStatus(BaseModel):
|
|
37
38
|
"""Status of all banking connections for a user."""
|
|
38
39
|
|
|
39
|
-
plaid:
|
|
40
|
-
teller:
|
|
41
|
-
mx:
|
|
40
|
+
plaid: BankingConnectionInfo | None = None
|
|
41
|
+
teller: BankingConnectionInfo | None = None
|
|
42
|
+
mx: BankingConnectionInfo | None = None
|
|
42
43
|
has_any_connection: bool = False
|
|
43
44
|
|
|
44
45
|
@property
|
|
@@ -54,7 +55,7 @@ class BankingConnectionStatus(BaseModel):
|
|
|
54
55
|
return providers
|
|
55
56
|
|
|
56
57
|
@property
|
|
57
|
-
def primary_provider(self) ->
|
|
58
|
+
def primary_provider(self) -> str | None:
|
|
58
59
|
"""Primary provider (first connected, or most recently synced)."""
|
|
59
60
|
if not self.has_any_connection:
|
|
60
61
|
return None
|
|
@@ -179,7 +180,7 @@ def validate_provider_token(provider: str, access_token: str) -> bool:
|
|
|
179
180
|
return validator(access_token)
|
|
180
181
|
|
|
181
182
|
|
|
182
|
-
def parse_banking_providers(banking_providers:
|
|
183
|
+
def parse_banking_providers(banking_providers: dict[str, Any]) -> BankingConnectionStatus:
|
|
183
184
|
"""
|
|
184
185
|
Parse banking_providers JSON field into structured status.
|
|
185
186
|
|
|
@@ -257,7 +258,7 @@ def parse_banking_providers(banking_providers: Dict[str, Any]) -> BankingConnect
|
|
|
257
258
|
return status
|
|
258
259
|
|
|
259
260
|
|
|
260
|
-
def sanitize_connection_status(status: BankingConnectionStatus) ->
|
|
261
|
+
def sanitize_connection_status(status: BankingConnectionStatus) -> dict[str, Any]:
|
|
261
262
|
"""
|
|
262
263
|
Sanitize connection status for API responses (removes access tokens).
|
|
263
264
|
|
|
@@ -298,10 +299,10 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
|
|
|
298
299
|
|
|
299
300
|
|
|
300
301
|
def mark_connection_unhealthy(
|
|
301
|
-
banking_providers:
|
|
302
|
+
banking_providers: dict[str, Any],
|
|
302
303
|
provider: str,
|
|
303
304
|
error_message: str,
|
|
304
|
-
) ->
|
|
305
|
+
) -> dict[str, Any]:
|
|
305
306
|
"""
|
|
306
307
|
Mark a provider connection as unhealthy (for error handling).
|
|
307
308
|
|
|
@@ -329,15 +330,15 @@ def mark_connection_unhealthy(
|
|
|
329
330
|
|
|
330
331
|
banking_providers[provider]["is_healthy"] = False
|
|
331
332
|
banking_providers[provider]["error_message"] = error_message
|
|
332
|
-
banking_providers[provider]["error_at"] = datetime.now(
|
|
333
|
+
banking_providers[provider]["error_at"] = datetime.now(UTC).isoformat()
|
|
333
334
|
|
|
334
335
|
return banking_providers
|
|
335
336
|
|
|
336
337
|
|
|
337
338
|
def mark_connection_healthy(
|
|
338
|
-
banking_providers:
|
|
339
|
+
banking_providers: dict[str, Any],
|
|
339
340
|
provider: str,
|
|
340
|
-
) ->
|
|
341
|
+
) -> dict[str, Any]:
|
|
341
342
|
"""
|
|
342
343
|
Mark a provider connection as healthy (after successful sync).
|
|
343
344
|
|
|
@@ -362,14 +363,14 @@ def mark_connection_healthy(
|
|
|
362
363
|
|
|
363
364
|
banking_providers[provider]["is_healthy"] = True
|
|
364
365
|
banking_providers[provider]["error_message"] = None
|
|
365
|
-
banking_providers[provider]["last_synced_at"] = datetime.now(
|
|
366
|
+
banking_providers[provider]["last_synced_at"] = datetime.now(UTC).isoformat()
|
|
366
367
|
|
|
367
368
|
return banking_providers
|
|
368
369
|
|
|
369
370
|
|
|
370
371
|
def get_primary_access_token(
|
|
371
|
-
banking_providers:
|
|
372
|
-
) -> tuple[
|
|
372
|
+
banking_providers: dict[str, Any],
|
|
373
|
+
) -> tuple[str | None, str | None]:
|
|
373
374
|
"""
|
|
374
375
|
Get the primary access token and provider name.
|
|
375
376
|
|
|
@@ -401,7 +402,7 @@ def get_primary_access_token(
|
|
|
401
402
|
async def test_connection_health(
|
|
402
403
|
provider: BankingProvider,
|
|
403
404
|
access_token: str,
|
|
404
|
-
) -> tuple[bool,
|
|
405
|
+
) -> tuple[bool, str | None]:
|
|
405
406
|
"""
|
|
406
407
|
Test if a banking connection is healthy by making a lightweight API call.
|
|
407
408
|
|
|
@@ -437,7 +438,7 @@ async def test_connection_health(
|
|
|
437
438
|
return False, error_msg
|
|
438
439
|
|
|
439
440
|
|
|
440
|
-
def should_refresh_token(banking_providers:
|
|
441
|
+
def should_refresh_token(banking_providers: dict[str, Any], provider: str) -> bool:
|
|
441
442
|
"""
|
|
442
443
|
Check if a provider token should be refreshed.
|
|
443
444
|
|
|
@@ -468,14 +469,14 @@ def should_refresh_token(banking_providers: Dict[str, Any], provider: str) -> bo
|
|
|
468
469
|
last_synced = _parse_datetime(last_synced_str)
|
|
469
470
|
if last_synced:
|
|
470
471
|
# Refresh if not synced in 30 days
|
|
471
|
-
days_since_sync = (datetime.now(
|
|
472
|
+
days_since_sync = (datetime.now(UTC) - last_synced).days
|
|
472
473
|
if days_since_sync > 30:
|
|
473
474
|
return True
|
|
474
475
|
|
|
475
476
|
return False
|
|
476
477
|
|
|
477
478
|
|
|
478
|
-
def _parse_datetime(value: Any) ->
|
|
479
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
479
480
|
"""Parse datetime from various formats."""
|
|
480
481
|
if not value:
|
|
481
482
|
return None
|
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):
|
|
@@ -127,7 +129,7 @@ def easy_brokerage(
|
|
|
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,9 +208,9 @@ 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):
|
|
@@ -234,7 +236,7 @@ def add_brokerage(
|
|
|
234
236
|
account = brokerage_provider.get_account()
|
|
235
237
|
return account
|
|
236
238
|
except Exception as e:
|
|
237
|
-
raise HTTPException(status_code=500, detail=f"Error fetching account: {
|
|
239
|
+
raise HTTPException(status_code=500, detail=f"Error fetching account: {e!s}")
|
|
238
240
|
|
|
239
241
|
@router.get("/positions")
|
|
240
242
|
async def list_positions():
|
|
@@ -246,7 +248,7 @@ def add_brokerage(
|
|
|
246
248
|
positions = list(brokerage_provider.positions()) # Convert Iterable to list for len()
|
|
247
249
|
return {"positions": positions, "count": len(positions)}
|
|
248
250
|
except Exception as e:
|
|
249
|
-
raise HTTPException(status_code=500, detail=f"Error fetching positions: {
|
|
251
|
+
raise HTTPException(status_code=500, detail=f"Error fetching positions: {e!s}")
|
|
250
252
|
|
|
251
253
|
@router.get("/positions/{symbol}")
|
|
252
254
|
async def get_position(symbol: str):
|
|
@@ -259,9 +261,7 @@ def add_brokerage(
|
|
|
259
261
|
position = brokerage_provider.get_position(symbol)
|
|
260
262
|
return position
|
|
261
263
|
except Exception as e:
|
|
262
|
-
raise HTTPException(
|
|
263
|
-
status_code=404, detail=f"Position not found for {symbol}: {str(e)}"
|
|
264
|
-
)
|
|
264
|
+
raise HTTPException(status_code=404, detail=f"Position not found for {symbol}: {e!s}")
|
|
265
265
|
|
|
266
266
|
@router.delete("/positions/{symbol}")
|
|
267
267
|
async def close_position(symbol: str):
|
|
@@ -274,7 +274,7 @@ def add_brokerage(
|
|
|
274
274
|
order = brokerage_provider.close_position(symbol)
|
|
275
275
|
return {"message": f"Closing position for {symbol}", "order": order}
|
|
276
276
|
except Exception as e:
|
|
277
|
-
raise HTTPException(status_code=400, detail=f"Error closing position: {
|
|
277
|
+
raise HTTPException(status_code=400, detail=f"Error closing position: {e!s}")
|
|
278
278
|
|
|
279
279
|
@router.post("/orders")
|
|
280
280
|
async def submit_order(order_request: OrderRequest):
|
|
@@ -295,7 +295,7 @@ def add_brokerage(
|
|
|
295
295
|
)
|
|
296
296
|
return order
|
|
297
297
|
except Exception as e:
|
|
298
|
-
raise HTTPException(status_code=400, detail=f"Error submitting order: {
|
|
298
|
+
raise HTTPException(status_code=400, detail=f"Error submitting order: {e!s}")
|
|
299
299
|
|
|
300
300
|
@router.get("/orders")
|
|
301
301
|
async def list_orders(
|
|
@@ -312,7 +312,7 @@ def add_brokerage(
|
|
|
312
312
|
orders = brokerage_provider.list_orders(status=status, limit=limit)
|
|
313
313
|
return {"orders": orders, "count": len(orders)}
|
|
314
314
|
except Exception as e:
|
|
315
|
-
raise HTTPException(status_code=500, detail=f"Error fetching orders: {
|
|
315
|
+
raise HTTPException(status_code=500, detail=f"Error fetching orders: {e!s}")
|
|
316
316
|
|
|
317
317
|
@router.get("/orders/{order_id}")
|
|
318
318
|
async def get_order(order_id: str):
|
|
@@ -325,7 +325,7 @@ def add_brokerage(
|
|
|
325
325
|
order = brokerage_provider.get_order(order_id)
|
|
326
326
|
return order
|
|
327
327
|
except Exception as e:
|
|
328
|
-
raise HTTPException(status_code=404, detail=f"Order not found: {
|
|
328
|
+
raise HTTPException(status_code=404, detail=f"Order not found: {e!s}")
|
|
329
329
|
|
|
330
330
|
@router.delete("/orders/{order_id}")
|
|
331
331
|
async def cancel_order(order_id: str):
|
|
@@ -338,7 +338,7 @@ def add_brokerage(
|
|
|
338
338
|
brokerage_provider.cancel_order(order_id)
|
|
339
339
|
return {"message": f"Order {order_id} canceled successfully"}
|
|
340
340
|
except Exception as e:
|
|
341
|
-
raise HTTPException(status_code=400, detail=f"Error canceling order: {
|
|
341
|
+
raise HTTPException(status_code=400, detail=f"Error canceling order: {e!s}")
|
|
342
342
|
|
|
343
343
|
@router.get("/portfolio/history")
|
|
344
344
|
async def get_portfolio_history(
|
|
@@ -355,9 +355,7 @@ def add_brokerage(
|
|
|
355
355
|
history = brokerage_provider.get_portfolio_history(period=period, timeframe=timeframe)
|
|
356
356
|
return history
|
|
357
357
|
except Exception as e:
|
|
358
|
-
raise HTTPException(
|
|
359
|
-
status_code=500, detail=f"Error fetching portfolio history: {str(e)}"
|
|
360
|
-
)
|
|
358
|
+
raise HTTPException(status_code=500, detail=f"Error fetching portfolio history: {e!s}")
|
|
361
359
|
|
|
362
360
|
# Watchlist routes
|
|
363
361
|
@router.post("/watchlists")
|
|
@@ -375,7 +373,7 @@ def add_brokerage(
|
|
|
375
373
|
watchlist = brokerage_provider.create_watchlist(name=name, symbols=symbols)
|
|
376
374
|
return watchlist
|
|
377
375
|
except Exception as e:
|
|
378
|
-
raise HTTPException(status_code=400, detail=f"Error creating watchlist: {
|
|
376
|
+
raise HTTPException(status_code=400, detail=f"Error creating watchlist: {e!s}")
|
|
379
377
|
|
|
380
378
|
@router.get("/watchlists")
|
|
381
379
|
async def list_watchlists():
|
|
@@ -384,7 +382,7 @@ def add_brokerage(
|
|
|
384
382
|
watchlists = brokerage_provider.list_watchlists()
|
|
385
383
|
return {"watchlists": watchlists, "count": len(watchlists)}
|
|
386
384
|
except Exception as e:
|
|
387
|
-
raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {
|
|
385
|
+
raise HTTPException(status_code=500, detail=f"Error fetching watchlists: {e!s}")
|
|
388
386
|
|
|
389
387
|
@router.get("/watchlists/{watchlist_id}")
|
|
390
388
|
async def get_watchlist(watchlist_id: str):
|
|
@@ -397,7 +395,7 @@ def add_brokerage(
|
|
|
397
395
|
watchlist = brokerage_provider.get_watchlist(watchlist_id)
|
|
398
396
|
return watchlist
|
|
399
397
|
except Exception as e:
|
|
400
|
-
raise HTTPException(status_code=404, detail=f"Watchlist not found: {
|
|
398
|
+
raise HTTPException(status_code=404, detail=f"Watchlist not found: {e!s}")
|
|
401
399
|
|
|
402
400
|
@router.delete("/watchlists/{watchlist_id}")
|
|
403
401
|
async def delete_watchlist(watchlist_id: str):
|
|
@@ -410,7 +408,7 @@ def add_brokerage(
|
|
|
410
408
|
brokerage_provider.delete_watchlist(watchlist_id)
|
|
411
409
|
return {"message": f"Watchlist {watchlist_id} deleted successfully"}
|
|
412
410
|
except Exception as e:
|
|
413
|
-
raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {
|
|
411
|
+
raise HTTPException(status_code=400, detail=f"Error deleting watchlist: {e!s}")
|
|
414
412
|
|
|
415
413
|
@router.post("/watchlists/{watchlist_id}/symbols")
|
|
416
414
|
async def add_to_watchlist(
|
|
@@ -426,7 +424,7 @@ def add_brokerage(
|
|
|
426
424
|
watchlist = brokerage_provider.add_to_watchlist(watchlist_id, symbol)
|
|
427
425
|
return watchlist
|
|
428
426
|
except Exception as e:
|
|
429
|
-
raise HTTPException(status_code=400, detail=f"Error adding symbol: {
|
|
427
|
+
raise HTTPException(status_code=400, detail=f"Error adding symbol: {e!s}")
|
|
430
428
|
|
|
431
429
|
@router.delete("/watchlists/{watchlist_id}/symbols/{symbol}")
|
|
432
430
|
async def remove_from_watchlist(watchlist_id: str, symbol: str):
|
|
@@ -440,7 +438,7 @@ def add_brokerage(
|
|
|
440
438
|
watchlist = brokerage_provider.remove_from_watchlist(watchlist_id, symbol)
|
|
441
439
|
return watchlist
|
|
442
440
|
except Exception as e:
|
|
443
|
-
raise HTTPException(status_code=400, detail=f"Error removing symbol: {
|
|
441
|
+
raise HTTPException(status_code=400, detail=f"Error removing symbol: {e!s}")
|
|
444
442
|
|
|
445
443
|
# Mount router
|
|
446
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
|
|
|
@@ -206,7 +206,7 @@ class BudgetTracker:
|
|
|
206
206
|
self,
|
|
207
207
|
user_id: str,
|
|
208
208
|
type: Optional[str] = None,
|
|
209
|
-
) ->
|
|
209
|
+
) -> list[Budget]:
|
|
210
210
|
"""
|
|
211
211
|
Get all budgets for a user.
|
|
212
212
|
|
fin_infra/cashflows/__init__.py
CHANGED
|
@@ -29,7 +29,7 @@ import numpy_financial as npf
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
30
30
|
from fastapi import FastAPI
|
|
31
31
|
|
|
32
|
-
from .core import
|
|
32
|
+
from .core import irr, npv
|
|
33
33
|
|
|
34
34
|
__all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
|
|
35
35
|
|
|
@@ -173,10 +173,10 @@ def add_cashflows(
|
|
|
173
173
|
- Scoped docs at {prefix}/docs
|
|
174
174
|
"""
|
|
175
175
|
from pydantic import BaseModel, Field
|
|
176
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
176
177
|
|
|
177
178
|
# Import svc-infra public router (no auth - utility calculations)
|
|
178
179
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
179
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
180
180
|
|
|
181
181
|
# Request/Response models
|
|
182
182
|
class NPVRequest(BaseModel):
|
|
@@ -252,4 +252,4 @@ def add_cashflows(
|
|
|
252
252
|
# Mount router
|
|
253
253
|
app.include_router(router, include_in_schema=True)
|
|
254
254
|
|
|
255
|
-
print("
|
|
255
|
+
print("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
|
fin_infra/cashflows/core.py
CHANGED
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(
|
|
@@ -122,7 +121,7 @@ def add_categorization(
|
|
|
122
121
|
raise HTTPException(status_code=400, detail=str(e))
|
|
123
122
|
|
|
124
123
|
@router.get("/categories")
|
|
125
|
-
async def list_categories(group:
|
|
124
|
+
async def list_categories(group: CategoryGroup | None = None):
|
|
126
125
|
"""
|
|
127
126
|
List all available categories.
|
|
128
127
|
|
|
@@ -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
|
|
|
@@ -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
|
"""
|
|
@@ -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:
|
|
@@ -335,7 +338,7 @@ def get_engine() -> CategorizationEngine:
|
|
|
335
338
|
|
|
336
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
|
"""
|