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
fin_infra/banking/__init__.py
CHANGED
|
@@ -45,12 +45,12 @@ from __future__ import annotations
|
|
|
45
45
|
|
|
46
46
|
import os
|
|
47
47
|
from datetime import date
|
|
48
|
-
from typing import TYPE_CHECKING,
|
|
48
|
+
from typing import TYPE_CHECKING, cast
|
|
49
49
|
|
|
50
50
|
from pydantic import BaseModel, Field
|
|
51
51
|
|
|
52
|
-
from ..providers.registry import resolve
|
|
53
52
|
from ..providers.base import BankingProvider
|
|
53
|
+
from ..providers.registry import resolve
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
56
|
from fastapi import FastAPI
|
|
@@ -99,7 +99,7 @@ class ExchangeTokenResponse(BaseModel):
|
|
|
99
99
|
"""Response model for token exchange."""
|
|
100
100
|
|
|
101
101
|
access_token: str
|
|
102
|
-
item_id:
|
|
102
|
+
item_id: str | None = None
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
class BalanceHistoryStats(BaseModel):
|
|
@@ -174,7 +174,6 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
|
|
|
174
174
|
See Also:
|
|
175
175
|
- add_banking(): For FastAPI integration with routes
|
|
176
176
|
- docs/banking.md: Comprehensive banking integration guide
|
|
177
|
-
- docs/adr/0003-banking-integration.md: Architecture decisions
|
|
178
177
|
"""
|
|
179
178
|
# Auto-detect provider config from environment if not explicitly provided
|
|
180
179
|
# Only auto-detect if no config params were passed
|
|
@@ -199,11 +198,11 @@ def easy_banking(provider: str = "teller", **config) -> BankingProvider:
|
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
# Use provider registry to dynamically load and configure provider
|
|
202
|
-
return cast(BankingProvider, resolve("banking", provider, **config))
|
|
201
|
+
return cast("BankingProvider", resolve("banking", provider, **config))
|
|
203
202
|
|
|
204
203
|
|
|
205
204
|
def add_banking(
|
|
206
|
-
app:
|
|
205
|
+
app: FastAPI,
|
|
207
206
|
*,
|
|
208
207
|
provider: str | BankingProvider | None = None,
|
|
209
208
|
prefix: str = "/banking",
|
|
@@ -350,25 +349,25 @@ def add_banking(
|
|
|
350
349
|
@router.get("/transactions")
|
|
351
350
|
async def get_transactions(
|
|
352
351
|
access_token: str = Depends(get_access_token),
|
|
353
|
-
start_date:
|
|
354
|
-
end_date:
|
|
355
|
-
merchant:
|
|
352
|
+
start_date: date | None = Query(None, description="Filter by start date (ISO format)"),
|
|
353
|
+
end_date: date | None = Query(None, description="Filter by end date (ISO format)"),
|
|
354
|
+
merchant: str | None = Query(
|
|
356
355
|
None, description="Filter by merchant name (partial match, case-insensitive)"
|
|
357
356
|
),
|
|
358
|
-
category:
|
|
357
|
+
category: str | None = Query(
|
|
359
358
|
None, description="Filter by category (comma-separated list for multiple)"
|
|
360
359
|
),
|
|
361
|
-
min_amount:
|
|
360
|
+
min_amount: float | None = Query(
|
|
362
361
|
None, description="Minimum transaction amount (inclusive)"
|
|
363
362
|
),
|
|
364
|
-
max_amount:
|
|
363
|
+
max_amount: float | None = Query(
|
|
365
364
|
None, description="Maximum transaction amount (inclusive)"
|
|
366
365
|
),
|
|
367
|
-
tags:
|
|
368
|
-
account_id:
|
|
369
|
-
is_recurring:
|
|
370
|
-
sort_by:
|
|
371
|
-
order:
|
|
366
|
+
tags: str | None = Query(None, description="Filter by tags (comma-separated list)"),
|
|
367
|
+
account_id: str | None = Query(None, description="Filter by specific account ID"),
|
|
368
|
+
is_recurring: bool | None = Query(None, description="Filter by recurring status"),
|
|
369
|
+
sort_by: str | None = Query("date", description="Sort field: date, amount, or merchant"),
|
|
370
|
+
order: str | None = Query("desc", description="Sort order: asc or desc"),
|
|
372
371
|
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
|
373
372
|
per_page: int = Query(50, ge=1, le=200, description="Items per page (max 200)"),
|
|
374
373
|
):
|
|
@@ -476,7 +475,7 @@ def add_banking(
|
|
|
476
475
|
@router.get("/balances")
|
|
477
476
|
async def get_balances(
|
|
478
477
|
access_token: str = Depends(get_access_token),
|
|
479
|
-
account_id:
|
|
478
|
+
account_id: str | None = Query(None),
|
|
480
479
|
):
|
|
481
480
|
"""Get current balances."""
|
|
482
481
|
balances = banking.balances(
|
|
@@ -593,17 +592,17 @@ def add_banking(
|
|
|
593
592
|
|
|
594
593
|
# Import utilities at end to avoid circular imports
|
|
595
594
|
from .utils import ( # noqa: E402
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
595
|
+
BankingConnectionInfo,
|
|
596
|
+
BankingConnectionStatus,
|
|
597
|
+
get_primary_access_token,
|
|
598
|
+
mark_connection_healthy,
|
|
599
|
+
mark_connection_unhealthy,
|
|
600
600
|
parse_banking_providers,
|
|
601
601
|
sanitize_connection_status,
|
|
602
|
-
mark_connection_unhealthy,
|
|
603
|
-
mark_connection_healthy,
|
|
604
|
-
get_primary_access_token,
|
|
605
|
-
test_connection_health,
|
|
606
602
|
should_refresh_token,
|
|
607
|
-
|
|
608
|
-
|
|
603
|
+
test_connection_health,
|
|
604
|
+
validate_mx_token,
|
|
605
|
+
validate_plaid_token,
|
|
606
|
+
validate_provider_token,
|
|
607
|
+
validate_teller_token,
|
|
609
608
|
)
|
fin_infra/banking/history.py
CHANGED
|
@@ -4,7 +4,7 @@ This module provides functionality to record and retrieve historical account bal
|
|
|
4
4
|
snapshots over time. This enables balance trend analysis, sparklines, and time-series
|
|
5
5
|
visualizations in fintech dashboards.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
[!] WARNING: This module uses IN-MEMORY storage by default. All data is LOST on restart.
|
|
8
8
|
For production use, integrate with svc-infra SQL database or set FIN_INFRA_STORAGE_BACKEND.
|
|
9
9
|
|
|
10
10
|
Features:
|
|
@@ -42,9 +42,8 @@ from __future__ import annotations
|
|
|
42
42
|
import logging
|
|
43
43
|
import os
|
|
44
44
|
from datetime import date, datetime, timedelta
|
|
45
|
-
from typing import List, Optional
|
|
46
|
-
from pydantic import BaseModel, Field, ConfigDict
|
|
47
45
|
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
48
47
|
|
|
49
48
|
__all__ = [
|
|
50
49
|
"BalanceSnapshot",
|
|
@@ -58,8 +57,8 @@ __all__ = [
|
|
|
58
57
|
_logger = logging.getLogger(__name__)
|
|
59
58
|
|
|
60
59
|
# In-memory storage for testing (will be replaced with SQL database in production)
|
|
61
|
-
#
|
|
62
|
-
_balance_snapshots:
|
|
60
|
+
# [!] WARNING: All data is LOST on restart when using in-memory storage!
|
|
61
|
+
_balance_snapshots: list[BalanceSnapshot] = []
|
|
63
62
|
_production_warning_logged = False
|
|
64
63
|
|
|
65
64
|
|
|
@@ -74,7 +73,7 @@ def _check_in_memory_warning() -> None:
|
|
|
74
73
|
|
|
75
74
|
if env in ("production", "staging") and storage_backend == "memory":
|
|
76
75
|
_logger.warning(
|
|
77
|
-
"
|
|
76
|
+
"[!] CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
|
|
78
77
|
"All balance snapshots will be LOST on restart. "
|
|
79
78
|
"Set FIN_INFRA_STORAGE_BACKEND=sql for production persistence.",
|
|
80
79
|
env,
|
|
@@ -115,7 +114,7 @@ def record_balance_snapshot(
|
|
|
115
114
|
This function stores a point-in-time balance record for trend analysis.
|
|
116
115
|
In production, this would write to a SQL database via svc-infra.
|
|
117
116
|
|
|
118
|
-
|
|
117
|
+
[!] WARNING: Uses in-memory storage by default. Data is LOST on restart!
|
|
119
118
|
|
|
120
119
|
Args:
|
|
121
120
|
account_id: Account identifier
|
|
@@ -155,9 +154,9 @@ def record_balance_snapshot(
|
|
|
155
154
|
def get_balance_history(
|
|
156
155
|
account_id: str,
|
|
157
156
|
days: int = 90,
|
|
158
|
-
start_date:
|
|
159
|
-
end_date:
|
|
160
|
-
) ->
|
|
157
|
+
start_date: date | None = None,
|
|
158
|
+
end_date: date | None = None,
|
|
159
|
+
) -> list[BalanceSnapshot]:
|
|
161
160
|
"""Get balance history for an account.
|
|
162
161
|
|
|
163
162
|
Retrieves balance snapshots for the specified account within a date range.
|
|
@@ -216,8 +215,8 @@ def get_balance_history(
|
|
|
216
215
|
|
|
217
216
|
def get_balance_snapshots(
|
|
218
217
|
account_id: str,
|
|
219
|
-
dates:
|
|
220
|
-
) ->
|
|
218
|
+
dates: list[date],
|
|
219
|
+
) -> list[BalanceSnapshot]:
|
|
221
220
|
"""Get balance snapshots for specific dates.
|
|
222
221
|
|
|
223
222
|
Args:
|
|
@@ -248,7 +247,7 @@ def get_balance_snapshots(
|
|
|
248
247
|
|
|
249
248
|
def delete_balance_history(
|
|
250
249
|
account_id: str,
|
|
251
|
-
before_date:
|
|
250
|
+
before_date: date | None = None,
|
|
252
251
|
) -> int:
|
|
253
252
|
"""Delete balance history for an account.
|
|
254
253
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Brokerage module - easy setup for trading operations.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[!] **TRADING WARNING**: This module provides real trading capabilities.
|
|
4
4
|
Always use paper trading mode for development and testing.
|
|
5
5
|
Live trading requires explicit opt-in and involves real financial risk.
|
|
6
6
|
|
|
@@ -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):
|
|
@@ -49,11 +51,11 @@ def easy_brokerage(
|
|
|
49
51
|
) -> BrokerageProvider:
|
|
50
52
|
"""Create a brokerage provider with paper/live trading support.
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
[!] **SAFETY**: Defaults to paper trading mode. Live trading requires explicit mode="live".
|
|
53
55
|
|
|
54
56
|
Auto-detects provider based on environment variables:
|
|
55
|
-
1. If ALPACA_API_KEY and ALPACA_API_SECRET are set
|
|
56
|
-
2. Otherwise
|
|
57
|
+
1. If ALPACA_API_KEY and ALPACA_API_SECRET are set -> Alpaca
|
|
58
|
+
2. Otherwise -> Raises error (credentials required)
|
|
57
59
|
|
|
58
60
|
Args:
|
|
59
61
|
provider: Provider name ("alpaca"). If None, defaults to alpaca.
|
|
@@ -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",
|
|
@@ -136,7 +138,7 @@ def add_brokerage(
|
|
|
136
138
|
) -> BrokerageProvider:
|
|
137
139
|
"""Wire brokerage provider to FastAPI app with routes and safety checks.
|
|
138
140
|
|
|
139
|
-
|
|
141
|
+
[!] **TRADING WARNING**: This mounts trading API endpoints.
|
|
140
142
|
Always use paper trading mode for development.
|
|
141
143
|
Live trading requires explicit mode="live" and proper safeguards.
|
|
142
144
|
|
|
@@ -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,13 +274,13 @@ 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):
|
|
281
281
|
"""Submit a new order.
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
[!] **TRADING WARNING**: This endpoint executes real trades in live mode.
|
|
284
284
|
"""
|
|
285
285
|
try:
|
|
286
286
|
order = brokerage_provider.submit_order(
|
|
@@ -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)
|
|
@@ -449,7 +447,7 @@ def add_brokerage(
|
|
|
449
447
|
add_prefixed_docs(
|
|
450
448
|
app,
|
|
451
449
|
prefix=prefix,
|
|
452
|
-
title="Brokerage" + (" (Paper Trading)" if mode == "paper" else "
|
|
450
|
+
title="Brokerage" + (" (Paper Trading)" if mode == "paper" else " [!] LIVE"),
|
|
453
451
|
auto_exclude_from_root=True,
|
|
454
452
|
visible_envs=None, # Show in all environments
|
|
455
453
|
)
|
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]
|