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.
Files changed (131) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +24 -24
  4. fin_infra/analytics/cash_flow.py +3 -3
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/models.py +5 -5
  7. fin_infra/analytics/portfolio.py +18 -18
  8. fin_infra/analytics/projections.py +1 -3
  9. fin_infra/analytics/spending.py +4 -5
  10. fin_infra/banking/__init__.py +27 -28
  11. fin_infra/banking/history.py +12 -13
  12. fin_infra/banking/utils.py +27 -26
  13. fin_infra/brokerage/__init__.py +29 -31
  14. fin_infra/budgets/__init__.py +3 -3
  15. fin_infra/budgets/add.py +16 -17
  16. fin_infra/budgets/alerts.py +4 -4
  17. fin_infra/budgets/ease.py +1 -2
  18. fin_infra/budgets/models.py +1 -2
  19. fin_infra/budgets/templates.py +4 -4
  20. fin_infra/budgets/tracker.py +4 -4
  21. fin_infra/cashflows/__init__.py +3 -3
  22. fin_infra/cashflows/core.py +1 -1
  23. fin_infra/categorization/__init__.py +1 -1
  24. fin_infra/categorization/add.py +2 -3
  25. fin_infra/categorization/ease.py +3 -3
  26. fin_infra/categorization/engine.py +18 -15
  27. fin_infra/categorization/llm_layer.py +13 -10
  28. fin_infra/categorization/models.py +3 -4
  29. fin_infra/categorization/rules.py +2 -4
  30. fin_infra/categorization/taxonomy.py +2 -2
  31. fin_infra/chat/__init__.py +6 -6
  32. fin_infra/chat/planning.py +1 -2
  33. fin_infra/cli/cmds/scaffold_cmds.py +16 -17
  34. fin_infra/clients/__init__.py +23 -1
  35. fin_infra/clients/base.py +1 -1
  36. fin_infra/clients/plaid.py +2 -2
  37. fin_infra/compliance/__init__.py +5 -4
  38. fin_infra/credit/add.py +6 -7
  39. fin_infra/credit/experian/auth.py +2 -2
  40. fin_infra/credit/experian/client.py +1 -1
  41. fin_infra/credit/experian/parser.py +5 -5
  42. fin_infra/credit/experian/provider.py +4 -4
  43. fin_infra/crypto/__init__.py +9 -11
  44. fin_infra/crypto/insights.py +4 -3
  45. fin_infra/documents/add.py +6 -8
  46. fin_infra/documents/analysis.py +9 -9
  47. fin_infra/documents/ease.py +14 -14
  48. fin_infra/documents/models.py +5 -6
  49. fin_infra/documents/ocr.py +7 -7
  50. fin_infra/documents/storage.py +21 -13
  51. fin_infra/exceptions.py +0 -1
  52. fin_infra/goals/__init__.py +8 -8
  53. fin_infra/goals/add.py +36 -36
  54. fin_infra/goals/funding.py +4 -6
  55. fin_infra/goals/management.py +5 -6
  56. fin_infra/goals/milestones.py +7 -8
  57. fin_infra/goals/models.py +9 -13
  58. fin_infra/insights/__init__.py +6 -3
  59. fin_infra/insights/aggregator.py +1 -1
  60. fin_infra/investments/__init__.py +3 -3
  61. fin_infra/investments/add.py +23 -23
  62. fin_infra/investments/ease.py +2 -2
  63. fin_infra/investments/models.py +27 -29
  64. fin_infra/investments/providers/base.py +12 -13
  65. fin_infra/investments/providers/plaid.py +52 -26
  66. fin_infra/investments/providers/snaptrade.py +19 -19
  67. fin_infra/investments/scaffold_templates/README.md +17 -17
  68. fin_infra/markets/__init__.py +7 -5
  69. fin_infra/models/__init__.py +10 -10
  70. fin_infra/models/accounts.py +4 -5
  71. fin_infra/models/brokerage.py +2 -1
  72. fin_infra/models/candle.py +1 -0
  73. fin_infra/models/money.py +1 -0
  74. fin_infra/models/quotes.py +4 -3
  75. fin_infra/models/tax.py +2 -1
  76. fin_infra/models/transactions.py +4 -5
  77. fin_infra/net_worth/__init__.py +8 -1
  78. fin_infra/net_worth/aggregator.py +5 -3
  79. fin_infra/net_worth/calculator.py +1 -1
  80. fin_infra/net_worth/insights.py +7 -8
  81. fin_infra/normalization/__init__.py +4 -4
  82. fin_infra/normalization/currency_converter.py +7 -8
  83. fin_infra/normalization/models.py +9 -10
  84. fin_infra/normalization/providers/exchangerate.py +5 -5
  85. fin_infra/normalization/providers/static_mappings.py +1 -1
  86. fin_infra/normalization/symbol_resolver.py +3 -4
  87. fin_infra/obs/classifier.py +3 -3
  88. fin_infra/providers/banking/plaid_client.py +5 -5
  89. fin_infra/providers/banking/teller_client.py +7 -6
  90. fin_infra/providers/base.py +27 -2
  91. fin_infra/providers/brokerage/alpaca.py +4 -4
  92. fin_infra/providers/market/alphavantage.py +6 -11
  93. fin_infra/providers/market/ccxt_crypto.py +19 -3
  94. fin_infra/providers/market/coingecko.py +5 -6
  95. fin_infra/providers/market/yahoo.py +23 -8
  96. fin_infra/providers/tax/__init__.py +1 -1
  97. fin_infra/providers/tax/irs.py +1 -1
  98. fin_infra/providers/tax/mock.py +5 -5
  99. fin_infra/providers/tax/taxbit.py +1 -1
  100. fin_infra/recurring/__init__.py +6 -6
  101. fin_infra/recurring/add.py +6 -5
  102. fin_infra/recurring/detector.py +7 -7
  103. fin_infra/recurring/detectors_llm.py +10 -10
  104. fin_infra/recurring/ease.py +6 -8
  105. fin_infra/recurring/insights.py +25 -24
  106. fin_infra/recurring/normalizer.py +7 -7
  107. fin_infra/recurring/normalizers.py +31 -30
  108. fin_infra/recurring/summary.py +13 -15
  109. fin_infra/scaffold/budgets.py +9 -9
  110. fin_infra/scaffold/goals.py +9 -9
  111. fin_infra/security/__init__.py +8 -8
  112. fin_infra/security/add.py +1 -2
  113. fin_infra/security/audit.py +6 -7
  114. fin_infra/security/encryption.py +6 -6
  115. fin_infra/security/models.py +7 -7
  116. fin_infra/security/pii_filter.py +16 -16
  117. fin_infra/security/token_store.py +2 -3
  118. fin_infra/settings.py +2 -1
  119. fin_infra/tax/__init__.py +1 -1
  120. fin_infra/tax/add.py +5 -4
  121. fin_infra/tax/tlh.py +10 -10
  122. fin_infra/utils/__init__.py +15 -1
  123. fin_infra/utils/deprecation.py +161 -0
  124. fin_infra/utils/http.py +4 -3
  125. fin_infra/utils/retry.py +2 -1
  126. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
  127. fin_infra-0.4.0.dist-info/RECORD +181 -0
  128. fin_infra-0.1.69.dist-info/RECORD +0 -180
  129. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
  130. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
  131. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -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, Optional, cast
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: Optional[str] = None
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: "FastAPI",
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: Optional[date] = Query(None, description="Filter by start date (ISO format)"),
354
- end_date: Optional[date] = Query(None, description="Filter by end date (ISO format)"),
355
- merchant: Optional[str] = Query(
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: Optional[str] = Query(
357
+ category: str | None = Query(
359
358
  None, description="Filter by category (comma-separated list for multiple)"
360
359
  ),
361
- min_amount: Optional[float] = Query(
360
+ min_amount: float | None = Query(
362
361
  None, description="Minimum transaction amount (inclusive)"
363
362
  ),
364
- max_amount: Optional[float] = Query(
363
+ max_amount: float | None = Query(
365
364
  None, description="Maximum transaction amount (inclusive)"
366
365
  ),
367
- tags: Optional[str] = Query(None, description="Filter by tags (comma-separated list)"),
368
- account_id: Optional[str] = Query(None, description="Filter by specific account ID"),
369
- is_recurring: Optional[bool] = Query(None, description="Filter by recurring status"),
370
- sort_by: Optional[str] = Query("date", description="Sort field: date, amount, or merchant"),
371
- order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
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: Optional[str] = Query(None),
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
- validate_plaid_token,
597
- validate_teller_token,
598
- validate_mx_token,
599
- validate_provider_token,
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
- BankingConnectionInfo,
608
- BankingConnectionStatus,
603
+ test_connection_health,
604
+ validate_mx_token,
605
+ validate_plaid_token,
606
+ validate_provider_token,
607
+ validate_teller_token,
609
608
  )
@@ -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
- ⚠️ WARNING: This module uses IN-MEMORY storage by default. All data is LOST on restart.
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
- # ⚠️ WARNING: All data is LOST on restart when using in-memory storage!
62
- _balance_snapshots: List[BalanceSnapshot] = []
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
- "⚠️ CRITICAL: Balance history using IN-MEMORY storage in %s environment! "
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
- ⚠️ WARNING: Uses in-memory storage by default. Data is LOST on restart!
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: Optional[date] = None,
159
- end_date: Optional[date] = None,
160
- ) -> List[BalanceSnapshot]:
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: List[date],
220
- ) -> List[BalanceSnapshot]:
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: Optional[date] = None,
250
+ before_date: date | None = None,
252
251
  ) -> int:
253
252
  """Delete balance history for an account.
254
253
 
@@ -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 datetime, timezone
12
- from typing import Any, Dict, Optional, Literal
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: Optional[str] = Field(
26
+ access_token: str | None = Field(
26
27
  None, description="Token (only for internal use, never expose)"
27
28
  )
28
- item_id: Optional[str] = None
29
- enrollment_id: Optional[str] = None
30
- connected_at: Optional[datetime] = None
31
- last_synced_at: Optional[datetime] = None
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: Optional[str] = None
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: Optional[BankingConnectionInfo] = None
40
- teller: Optional[BankingConnectionInfo] = None
41
- mx: Optional[BankingConnectionInfo] = None
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) -> Optional[str]:
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: Dict[str, Any]) -> BankingConnectionStatus:
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) -> Dict[str, Any]:
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: Dict[str, Any],
302
+ banking_providers: dict[str, Any],
302
303
  provider: str,
303
304
  error_message: str,
304
- ) -> Dict[str, Any]:
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(timezone.utc).isoformat()
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: Dict[str, Any],
339
+ banking_providers: dict[str, Any],
339
340
  provider: str,
340
- ) -> Dict[str, Any]:
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(timezone.utc).isoformat()
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: Dict[str, Any],
372
- ) -> tuple[Optional[str], Optional[str]]:
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, Optional[str]]:
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: Dict[str, Any], provider: str) -> bool:
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(timezone.utc) - last_synced).days
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) -> Optional[datetime]:
479
+ def _parse_datetime(value: Any) -> datetime | None:
479
480
  """Parse datetime from various formats."""
480
481
  if not value:
481
482
  return None
@@ -1,6 +1,6 @@
1
1
  """Brokerage module - easy setup for trading operations.
2
2
 
3
- ⚠️ **TRADING WARNING**: This module provides real trading capabilities.
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
- ⚠️ **SAFETY**: Defaults to paper trading mode. Live trading requires explicit mode="live".
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 Alpaca
56
- 2. Otherwise Raises error (credentials required)
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: "FastAPI",
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
- ⚠️ **TRADING WARNING**: This mounts trading API endpoints.
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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
- ⚠️ **TRADING WARNING**: This endpoint executes real trades in live mode.
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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 " ⚠️ LIVE"),
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
  )
@@ -105,12 +105,12 @@ def __getattr__(name: str):
105
105
  ):
106
106
  from fin_infra.budgets.models import ( # noqa: F401
107
107
  Budget,
108
- BudgetType,
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]