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
@@ -18,15 +18,15 @@ These templates generate production-ready persistence code for investment holdin
18
18
  ## Why Historical Snapshots?
19
19
 
20
20
  **Investment data providers (Plaid, SnapTrade, etc.) only provide current/live data:**
21
- - No historical portfolio values from past dates
22
- - No historical performance metrics
23
- - Cannot answer "What was my portfolio worth 3 months ago?"
21
+ - [X] No historical portfolio values from past dates
22
+ - [X] No historical performance metrics
23
+ - [X] Cannot answer "What was my portfolio worth 3 months ago?"
24
24
 
25
25
  **Solution: Store periodic snapshots in your database**
26
- - Track portfolio value changes over time
27
- - Calculate performance metrics (returns, growth)
28
- - Show trend charts and historical analysis
29
- - Works even if user disconnects provider
26
+ - [OK] Track portfolio value changes over time
27
+ - [OK] Calculate performance metrics (returns, growth)
28
+ - [OK] Show trend charts and historical analysis
29
+ - [OK] Works even if user disconnects provider
30
30
 
31
31
  ## Template Variables
32
32
 
@@ -148,12 +148,12 @@ async def capture_holdings_snapshot(
148
148
  holdings_data: list[dict], # From Plaid/SnapTrade API
149
149
  ) -> None:
150
150
  """Capture current holdings as a snapshot for historical tracking."""
151
-
151
+
152
152
  # Calculate aggregated metrics
153
153
  total_value = sum(Decimal(str(h.get("institution_value", 0))) for h in holdings_data)
154
154
  total_cost_basis = sum(Decimal(str(h.get("cost_basis", 0))) for h in holdings_data if h.get("cost_basis"))
155
155
  total_unrealized_gain_loss = sum(Decimal(str(h.get("unrealized_gain_loss", 0))) for h in holdings_data if h.get("unrealized_gain_loss"))
156
-
156
+
157
157
  # Create snapshot
158
158
  service = create_holding_snapshot_service(session)
159
159
  snapshot = await service.create(HoldingSnapshotCreate(
@@ -167,7 +167,7 @@ async def capture_holdings_snapshot(
167
167
  provider="plaid", # or "snaptrade"
168
168
  notes="Automatic daily snapshot"
169
169
  ))
170
-
170
+
171
171
  await session.commit()
172
172
  ```
173
173
 
@@ -181,18 +181,18 @@ async def daily_holdings_snapshot():
181
181
  """Capture holdings snapshots for all users with investment accounts."""
182
182
  from sqlalchemy import select
183
183
  from my_app.models.user import User
184
-
184
+
185
185
  async with AsyncSession(engine) as session:
186
186
  # Get all users with Plaid/SnapTrade connections
187
187
  stmt = select(User).where(User.banking_providers.isnot(None))
188
188
  result = await session.execute(stmt)
189
189
  users = result.scalars().all()
190
-
190
+
191
191
  for user in users:
192
192
  try:
193
193
  # Fetch current holdings from provider
194
194
  holdings = await fetch_holdings_from_provider(user)
195
-
195
+
196
196
  # Create snapshot
197
197
  await capture_holdings_snapshot(session, user.id, holdings)
198
198
  except Exception as e:
@@ -299,10 +299,10 @@ async def get_portfolio_performance_data(user_id: str):
299
299
  """Get data for portfolio performance dashboard."""
300
300
  async with AsyncSession(engine) as session:
301
301
  repo = create_holding_snapshot_service(session)
302
-
302
+
303
303
  # Get last 12 months trend
304
304
  snapshots = await repo.get_trend(user_id=user_id, months=12)
305
-
305
+
306
306
  # Calculate YTD performance
307
307
  today = date.today()
308
308
  year_start = date(today.year, 1, 1)
@@ -311,10 +311,10 @@ async def get_portfolio_performance_data(user_id: str):
311
311
  start_date=year_start,
312
312
  end_date=today
313
313
  )
314
-
314
+
315
315
  # Get latest snapshot
316
316
  latest = await repo.get_latest(user_id=user_id)
317
-
317
+
318
318
  return {
319
319
  "current_value": latest.total_value if latest else 0,
320
320
  "ytd_return": ytd_performance["percent_return"],
@@ -20,7 +20,10 @@ if TYPE_CHECKING:
20
20
  from fastapi import FastAPI
21
21
 
22
22
  from ..providers.base import MarketDataProvider
23
- from ..clients.base import MarketDataClient
23
+
24
+ # Deprecated: MarketDataClient alias for backward compatibility
25
+ # Use MarketDataProvider instead
26
+ MarketDataClient = MarketDataProvider # type: ignore[misc]
24
27
 
25
28
 
26
29
  def easy_market(
@@ -30,8 +33,8 @@ def easy_market(
30
33
  """Create a market data provider with zero or minimal configuration.
31
34
 
32
35
  Auto-detects provider based on environment variables:
33
- 1. If ALPHA_VANTAGE_API_KEY or ALPHAVANTAGE_API_KEY is set Alpha Vantage
34
- 2. Otherwise Yahoo Finance (no key needed)
36
+ 1. If ALPHA_VANTAGE_API_KEY or ALPHAVANTAGE_API_KEY is set -> Alpha Vantage
37
+ 2. Otherwise -> Yahoo Finance (no key needed)
35
38
 
36
39
  Args:
37
40
  provider: Provider name ("alphavantage" or "yahoo").
@@ -98,7 +101,7 @@ def easy_market(
98
101
 
99
102
 
100
103
  def add_market_data(
101
- app: "FastAPI",
104
+ app: FastAPI,
102
105
  *,
103
106
  provider: str | MarketDataProvider | None = None,
104
107
  prefix: str = "/market",
@@ -178,7 +181,6 @@ def add_market_data(
178
181
  See Also:
179
182
  - easy_market(): For standalone provider usage without FastAPI
180
183
  - docs/market-data.md: API documentation and examples
181
- - docs/adr/0004-market-data-integration.md: Architecture decisions
182
184
  """
183
185
  from fastapi import HTTPException, Query
184
186
 
@@ -1,21 +1,21 @@
1
1
  from .accounts import Account, AccountType
2
- from .transactions import Transaction
3
- from .quotes import Quote
4
- from .money import Money
5
- from .candle import Candle
6
- from .brokerage import Order, Position, PortfolioHistory
7
2
  from .brokerage import Account as BrokerageAccount # Avoid name conflict
3
+ from .brokerage import Order, PortfolioHistory, Position
4
+ from .candle import Candle
5
+ from .money import Money
6
+ from .quotes import Quote
8
7
  from .tax import (
8
+ CryptoTaxReport,
9
+ CryptoTransaction,
9
10
  TaxDocument,
10
- TaxFormW2,
11
- TaxForm1099INT,
12
- TaxForm1099DIV,
13
11
  TaxForm1099B,
12
+ TaxForm1099DIV,
13
+ TaxForm1099INT,
14
14
  TaxForm1099MISC,
15
- CryptoTransaction,
16
- CryptoTaxReport,
15
+ TaxFormW2,
17
16
  TaxLiability,
18
17
  )
18
+ from .transactions import Transaction
19
19
 
20
20
  __all__ = [
21
21
  "Account",
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from decimal import Decimal
4
4
  from enum import Enum
5
- from typing import Optional
6
5
 
7
6
  from pydantic import BaseModel, field_validator
8
7
 
@@ -26,11 +25,11 @@ class Account(BaseModel):
26
25
  id: str
27
26
  name: str
28
27
  type: AccountType
29
- mask: Optional[str] = None
28
+ mask: str | None = None
30
29
  currency: str = "USD"
31
- institution: Optional[str] = None
32
- balance_available: Optional[Decimal] = None
33
- balance_current: Optional[Decimal] = None
30
+ institution: str | None = None
31
+ balance_available: Decimal | None = None
32
+ balance_current: Decimal | None = None
34
33
 
35
34
  @field_validator("balance_available", "balance_current", mode="before")
36
35
  @classmethod
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from decimal import Decimal
6
5
  from datetime import datetime
6
+ from decimal import Decimal
7
7
  from typing import Literal
8
+
8
9
  from pydantic import BaseModel, Field
9
10
 
10
11
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from decimal import Decimal
4
+
4
5
  from pydantic import BaseModel, field_validator
5
6
 
6
7
 
fin_infra/models/money.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from decimal import Decimal
4
+
4
5
  from pydantic import BaseModel, field_validator
5
6
 
6
7
 
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
3
+ from datetime import UTC, datetime
4
4
  from decimal import Decimal
5
+
5
6
  from pydantic import BaseModel, field_validator
6
7
 
7
8
 
@@ -16,5 +17,5 @@ class Quote(BaseModel):
16
17
  def _ensure_tzaware(cls, v: datetime) -> datetime:
17
18
  # Normalize to timezone-aware (UTC) for consistency
18
19
  if v.tzinfo is None:
19
- return v.replace(tzinfo=timezone.utc)
20
- return v.astimezone(timezone.utc)
20
+ return v.replace(tzinfo=UTC)
21
+ return v.astimezone(UTC)
fin_infra/models/tax.py CHANGED
@@ -37,7 +37,8 @@ Example:
37
37
 
38
38
  from datetime import date, datetime
39
39
  from decimal import Decimal
40
- from pydantic import BaseModel, Field, ConfigDict
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field
41
42
 
42
43
 
43
44
  class TaxDocument(BaseModel):
@@ -1,8 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import date # noqa: F401 - used in type annotation
3
+ import datetime
4
4
  from decimal import Decimal
5
- from typing import Optional
6
5
 
7
6
  from pydantic import BaseModel, field_validator
8
7
 
@@ -16,11 +15,11 @@ class Transaction(BaseModel):
16
15
 
17
16
  id: str
18
17
  account_id: str
19
- date: date
18
+ date: datetime.date
20
19
  amount: Decimal
21
20
  currency: str = "USD"
22
- description: Optional[str] = None
23
- category: Optional[str] = None
21
+ description: str | None = None
22
+ category: str | None = None
24
23
 
25
24
  @field_validator("amount", mode="before")
26
25
  @classmethod
@@ -4,9 +4,16 @@ Net Worth Tracking Module
4
4
  Calculates net worth by aggregating balances from multiple financial providers
5
5
  (banking, brokerage, crypto) with historical snapshots and change detection.
6
6
 
7
+ **Feature Status**:
8
+ [OK] STABLE: Core calculation (works with provided data)
9
+ [OK] STABLE: Banking integration (Plaid, Teller)
10
+ [!] INTEGRATION: Brokerage integration (requires provider setup)
11
+ [!] INTEGRATION: Crypto integration (requires provider setup)
12
+ [!] INTEGRATION: Currency conversion (pass exchange_rate manually)
13
+
7
14
  **Key Features**:
8
15
  - Multi-provider aggregation (banking + brokerage + crypto)
9
- - Currency normalization (all currencies USD)
16
+ - Currency normalization (all currencies -> USD)
10
17
  - Historical snapshots (daily at midnight UTC)
11
18
  - Change detection (>5% or >$10k triggers webhook)
12
19
  - Asset allocation breakdown (pie charts)
@@ -30,6 +30,7 @@ print(f"Net Worth: ${snapshot.total_net_worth:,.2f}")
30
30
  """
31
31
 
32
32
  import asyncio
33
+ import logging
33
34
  import uuid
34
35
  from datetime import datetime
35
36
  from typing import Any
@@ -47,6 +48,8 @@ from fin_infra.net_worth.models import (
47
48
  NetWorthSnapshot,
48
49
  )
49
50
 
51
+ logger = logging.getLogger(__name__)
52
+
50
53
 
51
54
  class NetWorthAggregator:
52
55
  """
@@ -56,7 +59,7 @@ class NetWorthAggregator:
56
59
  - Multi-provider support (banking, brokerage, crypto)
57
60
  - Parallel account fetching (faster performance)
58
61
  - Graceful error handling (continue if one provider fails)
59
- - Currency normalization (all base currency)
62
+ - Currency normalization (all -> base currency)
60
63
  - Market value calculation (stocks/crypto)
61
64
 
62
65
  **Example**:
@@ -219,8 +222,7 @@ class NetWorthAggregator:
219
222
 
220
223
  for i, result in enumerate(results):
221
224
  if isinstance(result, BaseException):
222
- # Log error but continue (graceful degradation)
223
- print(f"Provider {providers_used[i]} failed: {result}")
225
+ logger.warning("Provider %s failed: %s", providers_used[i], result)
224
226
  continue
225
227
 
226
228
  # result is now tuple[list[AssetDetail], list[LiabilityDetail]]
@@ -3,7 +3,7 @@ Net Worth Calculator Module
3
3
 
4
4
  Provides core calculation functions for net worth tracking:
5
5
  - Net worth calculation (assets - liabilities)
6
- - Currency normalization (all currencies base currency)
6
+ - Currency normalization (all currencies -> base currency)
7
7
  - Asset allocation breakdown
8
8
  - Change detection (amount + percentage)
9
9
 
@@ -31,7 +31,6 @@ from typing import Any
31
31
 
32
32
  from pydantic import BaseModel, Field
33
33
 
34
-
35
34
  # ============================================================================
36
35
  # Pydantic Schemas (Structured Output)
37
36
  # ============================================================================
@@ -168,7 +167,7 @@ Be specific with numbers. Cite percentage changes and dollar amounts.
168
167
  Focus on actionable insights, not generic advice.
169
168
 
170
169
  Example 1:
171
- User: Net worth: $500k $575k over 6 months. Assets: +$65k (investments +$60k, savings +$5k). Liabilities: -$10k (new mortgage).
170
+ User: Net worth: $500k -> $575k over 6 months. Assets: +$65k (investments +$60k, savings +$5k). Liabilities: -$10k (new mortgage).
172
171
  Response: {
173
172
  "summary": "Net worth increased 15% ($75k) over 6 months, driven primarily by strong investment performance.",
174
173
  "period": "6 months",
@@ -192,7 +191,7 @@ Response: {
192
191
  }
193
192
 
194
193
  Example 2:
195
- User: Net worth: $100k $95k over 3 months. Assets: -$2k (market down). Liabilities: +$3k (credit card debt).
194
+ User: Net worth: $100k -> $95k over 3 months. Assets: -$2k (market down). Liabilities: +$3k (credit card debt).
196
195
  Response: {
197
196
  "summary": "Net worth decreased 5% ($5k) over 3 months due to market decline and rising credit card debt.",
198
197
  "period": "3 months",
@@ -216,7 +215,7 @@ Response: {
216
215
  "confidence": 0.89
217
216
  }
218
217
 
219
- ⚠️ This is AI-generated advice. Not a substitute for a certified financial advisor.
218
+ [!] This is AI-generated advice. Not a substitute for a certified financial advisor.
220
219
  Verify calculations independently. For personalized advice, consult a professional."""
221
220
 
222
221
  DEBT_REDUCTION_SYSTEM_PROMPT = """You are a debt counselor using the avalanche method (highest APR first).
@@ -270,7 +269,7 @@ Response: {
270
269
  "confidence": 0.98
271
270
  }
272
271
 
273
- ⚠️ This is AI-generated advice. Not a substitute for a certified financial advisor.
272
+ [!] This is AI-generated advice. Not a substitute for a certified financial advisor.
274
273
  Verify calculations independently. For personalized advice, consult a professional."""
275
274
 
276
275
  GOAL_RECOMMENDATION_SYSTEM_PROMPT = """You are a financial planner validating goals and suggesting paths.
@@ -323,7 +322,7 @@ Response: {
323
322
  "confidence": 0.89
324
323
  }
325
324
 
326
- ⚠️ This is AI-generated advice. Not a substitute for a certified financial advisor.
325
+ [!] This is AI-generated advice. Not a substitute for a certified financial advisor.
327
326
  Verify calculations independently. For personalized advice, consult a professional."""
328
327
 
329
328
  ASSET_ALLOCATION_SYSTEM_PROMPT = """You are a portfolio advisor recommending asset allocation.
@@ -334,7 +333,7 @@ Given current allocation, age, and risk tolerance:
334
333
  3. Provide specific rebalancing steps
335
334
 
336
335
  Rule of thumb:
337
- - Stock allocation = 100 - age (e.g., age 35 65% stocks)
336
+ - Stock allocation = 100 - age (e.g., age 35 -> 65% stocks)
338
337
  - Bonds for stability (increases with age)
339
338
  - Cash for emergency fund (3-6 months expenses)
340
339
 
@@ -366,7 +365,7 @@ Response: {
366
365
  "confidence": 0.91
367
366
  }
368
367
 
369
- ⚠️ This is AI-generated advice. Not a substitute for a certified financial advisor.
368
+ [!] This is AI-generated advice. Not a substitute for a certified financial advisor.
370
369
  Verify calculations independently. For personalized advice, consult a professional."""
371
370
 
372
371
 
@@ -54,7 +54,7 @@ def easy_normalization(
54
54
  Example:
55
55
  >>> from fin_infra.normalization import easy_normalization
56
56
  >>> resolver, converter = easy_normalization()
57
- >>> ticker = await resolver.to_ticker("037833100") # CUSIP AAPL
57
+ >>> ticker = await resolver.to_ticker("037833100") # CUSIP -> AAPL
58
58
  >>> eur = await converter.convert(100, "USD", "EUR") # 92.0
59
59
  """
60
60
  global _resolver_instance, _converter_instance
@@ -107,7 +107,7 @@ def add_normalization(
107
107
  >>> resolver, converter = add_normalization(app)
108
108
  >>>
109
109
  >>> # Routes available:
110
- >>> # GET /normalize/symbol/037833100 {"ticker": "AAPL", ...}
110
+ >>> # GET /normalize/symbol/037833100 -> {"ticker": "AAPL", ...}
111
111
  >>> # GET /normalize/convert?amount=100&from_currency=USD&to_currency=EUR
112
112
 
113
113
  Integration with svc-infra:
@@ -116,11 +116,11 @@ def add_normalization(
116
116
  - Scoped docs at {prefix}/docs for standalone documentation
117
117
  """
118
118
  # Import FastAPI dependencies
119
- from fastapi import Query, HTTPException
119
+ from fastapi import HTTPException, Query
120
+ from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
120
121
 
121
122
  # Import svc-infra public router (no auth - utility endpoints)
122
123
  from svc_infra.api.fastapi.dual.public import public_router
123
- from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
124
124
 
125
125
  # Get normalization services
126
126
  resolver, converter = easy_normalization(api_key=api_key)
@@ -2,7 +2,6 @@
2
2
 
3
3
  import logging
4
4
  from datetime import date as DateType
5
- from typing import Optional
6
5
 
7
6
  from fin_infra.exceptions import CurrencyNotSupportedError, ExchangeRateAPIError
8
7
  from fin_infra.normalization.models import CurrencyConversionResult
@@ -25,7 +24,7 @@ class CurrencyConverter:
25
24
  Supports 160+ currencies including crypto (BTC, ETH).
26
25
  """
27
26
 
28
- def __init__(self, api_key: Optional[str] = None):
27
+ def __init__(self, api_key: str | None = None):
29
28
  """
30
29
  Initialize currency converter.
31
30
 
@@ -39,7 +38,7 @@ class CurrencyConverter:
39
38
  amount: float,
40
39
  from_currency: str,
41
40
  to_currency: str,
42
- date: Optional[DateType] = None,
41
+ date: DateType | None = None,
43
42
  ) -> float:
44
43
  """
45
44
  Convert amount from one currency to another.
@@ -72,14 +71,14 @@ class CurrencyConverter:
72
71
  except ExchangeRateAPIError as e:
73
72
  logger.error(f"Failed to convert {from_currency} to {to_currency}: {e}")
74
73
  raise CurrencyNotSupportedError(
75
- f"Conversion failed: {from_currency} {to_currency}"
74
+ f"Conversion failed: {from_currency} -> {to_currency}"
76
75
  ) from e
77
76
 
78
77
  async def get_rate(
79
78
  self,
80
79
  from_currency: str,
81
80
  to_currency: str,
82
- date: Optional[DateType] = None,
81
+ date: DateType | None = None,
83
82
  ) -> float:
84
83
  """
85
84
  Get exchange rate between two currencies.
@@ -109,9 +108,9 @@ class CurrencyConverter:
109
108
  return rate_data.rate
110
109
 
111
110
  except ExchangeRateAPIError as e:
112
- logger.error(f"Failed to get rate {from_currency} {to_currency}: {e}")
111
+ logger.error(f"Failed to get rate {from_currency} -> {to_currency}: {e}")
113
112
  raise CurrencyNotSupportedError(
114
- f"Rate not available: {from_currency} {to_currency}"
113
+ f"Rate not available: {from_currency} -> {to_currency}"
115
114
  ) from e
116
115
 
117
116
  async def get_rates(self, base_currency: str = "USD") -> dict[str, float]:
@@ -142,7 +141,7 @@ class CurrencyConverter:
142
141
  amount: float,
143
142
  from_currency: str,
144
143
  to_currency: str,
145
- date: Optional[DateType] = None,
144
+ date: DateType | None = None,
146
145
  ) -> CurrencyConversionResult:
147
146
  """
148
147
  Convert amount with detailed result information.
@@ -1,7 +1,6 @@
1
1
  """Data models for normalization module."""
2
2
 
3
3
  from datetime import date as DateType
4
- from typing import Optional
5
4
 
6
5
  from pydantic import BaseModel, Field
7
6
 
@@ -11,12 +10,12 @@ class SymbolMetadata(BaseModel):
11
10
 
12
11
  ticker: str = Field(..., description="Standard ticker symbol")
13
12
  name: str = Field(..., description="Company or asset name")
14
- exchange: Optional[str] = Field(None, description="Primary exchange (e.g., NASDAQ, NYSE)")
15
- cusip: Optional[str] = Field(None, description="CUSIP identifier")
16
- isin: Optional[str] = Field(None, description="ISIN identifier")
17
- sector: Optional[str] = Field(None, description="Business sector")
18
- industry: Optional[str] = Field(None, description="Industry classification")
19
- market_cap: Optional[float] = Field(None, description="Market capitalization in USD")
13
+ exchange: str | None = Field(None, description="Primary exchange (e.g., NASDAQ, NYSE)")
14
+ cusip: str | None = Field(None, description="CUSIP identifier")
15
+ isin: str | None = Field(None, description="ISIN identifier")
16
+ sector: str | None = Field(None, description="Business sector")
17
+ industry: str | None = Field(None, description="Industry classification")
18
+ market_cap: float | None = Field(None, description="Market capitalization in USD")
20
19
  asset_type: str = Field(default="stock", description="Asset type: stock, etf, crypto, forex")
21
20
 
22
21
 
@@ -26,8 +25,8 @@ class ExchangeRate(BaseModel):
26
25
  from_currency: str = Field(..., description="Source currency code (e.g., USD)")
27
26
  to_currency: str = Field(..., description="Target currency code (e.g., EUR)")
28
27
  rate: float = Field(..., description="Exchange rate (1 from_currency = rate to_currency)")
29
- date: Optional[DateType] = Field(None, description="Rate date (None = current)")
30
- timestamp: Optional[int] = Field(None, description="Unix timestamp of rate")
28
+ date: DateType | None = Field(None, description="Rate date (None = current)")
29
+ timestamp: int | None = Field(None, description="Unix timestamp of rate")
31
30
 
32
31
 
33
32
  class CurrencyConversionResult(BaseModel):
@@ -38,4 +37,4 @@ class CurrencyConversionResult(BaseModel):
38
37
  to_currency: str = Field(..., description="Target currency")
39
38
  converted: float = Field(..., description="Converted amount")
40
39
  rate: float = Field(..., description="Exchange rate used")
41
- date: Optional[DateType] = Field(None, description="Rate date")
40
+ date: DateType | None = Field(None, description="Rate date")
@@ -2,7 +2,7 @@
2
2
 
3
3
  import os
4
4
  from datetime import date as DateType
5
- from typing import Optional, cast
5
+ from typing import cast
6
6
 
7
7
  import httpx
8
8
 
@@ -19,7 +19,7 @@ __all__ = [
19
19
  class ExchangeRateClient:
20
20
  """Client for exchangerate-api.io API."""
21
21
 
22
- def __init__(self, api_key: Optional[str] = None):
22
+ def __init__(self, api_key: str | None = None):
23
23
  """
24
24
  Initialize exchange rate client.
25
25
 
@@ -66,10 +66,10 @@ class ExchangeRateClient:
66
66
  raise ExchangeRateAPIError(
67
67
  f"API returned error: {data.get('error-type', 'unknown')}"
68
68
  )
69
- return cast(dict[str, float], data["conversion_rates"])
69
+ return cast("dict[str, float]", data["conversion_rates"])
70
70
  else:
71
71
  # Free tier response format
72
- return cast(dict[str, float], data["rates"])
72
+ return cast("dict[str, float]", data["rates"])
73
73
 
74
74
  except httpx.HTTPError as e:
75
75
  raise ExchangeRateAPIError(f"HTTP error fetching rates: {e}")
@@ -77,7 +77,7 @@ class ExchangeRateClient:
77
77
  raise ExchangeRateAPIError(f"Invalid API response: {e}")
78
78
 
79
79
  async def get_rate(
80
- self, from_currency: str, to_currency: str, date: Optional[DateType] = None
80
+ self, from_currency: str, to_currency: str, date: DateType | None = None
81
81
  ) -> ExchangeRate:
82
82
  """
83
83
  Get exchange rate between two currencies.
@@ -109,7 +109,7 @@ TICKER_TO_ISIN = {
109
109
  }
110
110
 
111
111
  # Provider-specific symbol normalization
112
- # Maps provider-specific format standard ticker
112
+ # Maps provider-specific format -> standard ticker
113
113
  PROVIDER_SYMBOL_MAP = {
114
114
  "yahoo": {
115
115
  # Yahoo Finance uses dashes for crypto
@@ -1,7 +1,6 @@
1
1
  """Symbol resolver for converting between ticker formats."""
2
2
 
3
3
  import logging
4
- from typing import Optional
5
4
 
6
5
  from fin_infra.exceptions import SymbolNotFoundError
7
6
  from fin_infra.normalization.models import SymbolMetadata
@@ -231,9 +230,9 @@ class SymbolResolver:
231
230
  def add_mapping(
232
231
  self,
233
232
  ticker: str,
234
- cusip: Optional[str] = None,
235
- isin: Optional[str] = None,
236
- metadata: Optional[dict] = None,
233
+ cusip: str | None = None,
234
+ isin: str | None = None,
235
+ metadata: dict | None = None,
237
236
  ):
238
237
  """
239
238
  Add or override a symbol mapping (useful for custom symbols).
@@ -37,7 +37,7 @@ Usage:
37
37
 
38
38
  from __future__ import annotations
39
39
 
40
- from typing import Callable
40
+ from collections.abc import Callable
41
41
 
42
42
  # Financial capability prefix patterns (extensible)
43
43
  FINANCIAL_ROUTE_PREFIXES = (
@@ -63,8 +63,8 @@ def financial_route_classifier(route_path: str, method: str) -> str:
63
63
  svc-infra's add_observability route_classifier parameter.
64
64
 
65
65
  Classification Logic:
66
- - Financial routes (e.g., /banking/*, /market/*) "financial"
67
- - All other routes "public"
66
+ - Financial routes (e.g., /banking/*, /market/*) -> "financial"
67
+ - All other routes -> "public"
68
68
 
69
69
  This allows Grafana dashboards to split metrics by route class:
70
70
  - Filter by route_class="financial" for financial provider SLOs