fin-infra 0.1.82__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/analytics/__init__.py +2 -2
- fin_infra/analytics/add.py +3 -3
- fin_infra/analytics/cash_flow.py +3 -3
- fin_infra/analytics/models.py +5 -5
- fin_infra/analytics/portfolio.py +12 -12
- fin_infra/analytics/spending.py +4 -5
- fin_infra/banking/history.py +4 -4
- fin_infra/brokerage/__init__.py +7 -7
- fin_infra/budgets/alerts.py +2 -2
- 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 +3 -3
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/ease.py +3 -3
- fin_infra/categorization/engine.py +1 -1
- fin_infra/categorization/llm_layer.py +6 -4
- fin_infra/categorization/models.py +3 -4
- fin_infra/chat/planning.py +1 -1
- fin_infra/cli/cmds/scaffold_cmds.py +6 -6
- fin_infra/credit/experian/parser.py +5 -5
- fin_infra/crypto/__init__.py +2 -2
- fin_infra/documents/models.py +2 -3
- fin_infra/goals/management.py +3 -3
- fin_infra/goals/milestones.py +6 -6
- fin_infra/goals/models.py +2 -2
- fin_infra/investments/__init__.py +2 -2
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/models.py +24 -26
- fin_infra/investments/providers/base.py +4 -4
- fin_infra/investments/scaffold_templates/README.md +17 -17
- fin_infra/markets/__init__.py +2 -2
- fin_infra/models/accounts.py +4 -5
- fin_infra/models/transactions.py +2 -2
- fin_infra/net_worth/__init__.py +6 -6
- fin_infra/net_worth/aggregator.py +1 -1
- fin_infra/net_worth/calculator.py +1 -1
- fin_infra/net_worth/insights.py +7 -7
- fin_infra/normalization/__init__.py +2 -2
- fin_infra/normalization/currency_converter.py +7 -8
- fin_infra/normalization/models.py +9 -10
- fin_infra/normalization/providers/static_mappings.py +1 -1
- fin_infra/normalization/symbol_resolver.py +3 -4
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/brokerage/alpaca.py +1 -1
- fin_infra/recurring/add.py +1 -1
- fin_infra/recurring/detectors_llm.py +5 -5
- fin_infra/recurring/ease.py +4 -4
- fin_infra/recurring/insights.py +15 -14
- fin_infra/recurring/normalizer.py +6 -6
- fin_infra/recurring/normalizers.py +27 -26
- fin_infra/scaffold/goals.py +4 -4
- fin_infra/security/add.py +1 -2
- fin_infra/security/audit.py +6 -7
- fin_infra/security/pii_filter.py +10 -10
- fin_infra/security/token_store.py +2 -3
- fin_infra/tax/add.py +2 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/__init__.py +15 -1
- fin_infra/utils/deprecation.py +161 -0
- {fin_infra-0.1.82.dist-info → fin_infra-0.4.0.dist-info}/METADATA +17 -9
- {fin_infra-0.1.82.dist-info → fin_infra-0.4.0.dist-info}/RECORD +65 -64
- {fin_infra-0.1.82.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.82.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.82.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
fin_infra/goals/milestones.py
CHANGED
|
@@ -22,7 +22,7 @@ Example:
|
|
|
22
22
|
# Check which milestones have been reached
|
|
23
23
|
reached = check_milestones("goal_123")
|
|
24
24
|
for m in reached:
|
|
25
|
-
print(f"
|
|
25
|
+
print(f" Milestone reached: {m['description']}")
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from datetime import datetime
|
|
@@ -124,7 +124,7 @@ def check_milestones(goal_id: str) -> list[dict[str, Any]]:
|
|
|
124
124
|
|
|
125
125
|
reached = check_milestones("goal_123")
|
|
126
126
|
if reached:
|
|
127
|
-
print(f"
|
|
127
|
+
print(f" {len(reached)} milestones reached!")
|
|
128
128
|
for m in reached:
|
|
129
129
|
print(f" - {m['description']}: ${m['amount']:,.0f}")
|
|
130
130
|
|
|
@@ -184,17 +184,17 @@ def get_celebration_message(milestone: dict[str, Any]) -> str:
|
|
|
184
184
|
|
|
185
185
|
Example:
|
|
186
186
|
message = get_celebration_message(milestone)
|
|
187
|
-
# "
|
|
187
|
+
# " Milestone reached! You've hit $25,000 - 25% to target!"
|
|
188
188
|
"""
|
|
189
189
|
amount = milestone["amount"]
|
|
190
190
|
description = milestone["description"]
|
|
191
191
|
|
|
192
192
|
messages = [
|
|
193
|
-
f"
|
|
193
|
+
f" Milestone reached! You've hit ${amount:,.0f} - {description}!",
|
|
194
194
|
f"🎊 Congratulations! ${amount:,.0f} milestone achieved - {description}",
|
|
195
195
|
f"🌟 Great progress! You reached ${amount:,.0f} - {description}",
|
|
196
|
-
f"
|
|
197
|
-
f"
|
|
196
|
+
f" Keep going! ${amount:,.0f} milestone completed - {description}",
|
|
197
|
+
f" Amazing! You hit ${amount:,.0f} - {description}",
|
|
198
198
|
]
|
|
199
199
|
|
|
200
200
|
# Use amount to pick consistent message for same milestone
|
fin_infra/goals/models.py
CHANGED
|
@@ -93,8 +93,8 @@ class FundingSource(BaseModel):
|
|
|
93
93
|
|
|
94
94
|
Supports split allocation:
|
|
95
95
|
- Multiple accounts can fund one goal (e.g., savings + checking)
|
|
96
|
-
- One account can fund multiple goals (e.g., savings
|
|
97
|
-
- Allocation percentages must sum to
|
|
96
|
+
- One account can fund multiple goals (e.g., savings -> emergency + vacation)
|
|
97
|
+
- Allocation percentages must sum to <=100% per account
|
|
98
98
|
"""
|
|
99
99
|
|
|
100
100
|
goal_id: str = Field(..., description="Goal identifier")
|
|
@@ -74,8 +74,8 @@ def easy_investments(
|
|
|
74
74
|
InvestmentProvider instance for fetching holdings, transactions, securities.
|
|
75
75
|
|
|
76
76
|
Environment detection order:
|
|
77
|
-
1. If PLAID_CLIENT_ID set
|
|
78
|
-
2. If SNAPTRADE_CLIENT_ID set
|
|
77
|
+
1. If PLAID_CLIENT_ID set -> Plaid
|
|
78
|
+
2. If SNAPTRADE_CLIENT_ID set -> SnapTrade
|
|
79
79
|
3. Default: Plaid (most common)
|
|
80
80
|
|
|
81
81
|
Examples:
|
fin_infra/investments/ease.py
CHANGED
|
@@ -8,13 +8,13 @@ and SnapTrade (retail brokerages) for maximum coverage.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
-
from typing import Any, Literal
|
|
11
|
+
from typing import Any, Literal
|
|
12
12
|
|
|
13
13
|
from .providers.base import InvestmentProvider
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def easy_investments(
|
|
17
|
-
provider:
|
|
17
|
+
provider: Literal["plaid", "snaptrade"] | None = None,
|
|
18
18
|
**config: Any,
|
|
19
19
|
) -> InvestmentProvider:
|
|
20
20
|
"""Create investment provider with auto-configuration.
|
fin_infra/investments/models.py
CHANGED
|
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
from datetime import date
|
|
21
21
|
from decimal import Decimal
|
|
22
22
|
from enum import Enum
|
|
23
|
-
from typing import TYPE_CHECKING
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
24
|
|
|
25
25
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
26
26
|
|
|
@@ -121,22 +121,20 @@ class Security(BaseModel):
|
|
|
121
121
|
|
|
122
122
|
# Identifiers (at least one required for matching)
|
|
123
123
|
security_id: str = Field(..., description="Provider-specific security ID")
|
|
124
|
-
cusip:
|
|
125
|
-
isin:
|
|
126
|
-
sedol:
|
|
127
|
-
ticker_symbol:
|
|
124
|
+
cusip: str | None = Field(None, description="CUSIP identifier (US securities)")
|
|
125
|
+
isin: str | None = Field(None, description="ISIN identifier (international)")
|
|
126
|
+
sedol: str | None = Field(None, description="SEDOL identifier (UK securities)")
|
|
127
|
+
ticker_symbol: str | None = Field(None, description="Trading symbol (AAPL, GOOGL)")
|
|
128
128
|
|
|
129
129
|
# Basic info
|
|
130
130
|
name: str = Field(..., description="Security name")
|
|
131
131
|
type: SecurityType = Field(..., description="Security type (equity, etf, bond, etc.)")
|
|
132
|
-
sector:
|
|
133
|
-
None, description="Sector classification (Technology, Healthcare)"
|
|
134
|
-
)
|
|
132
|
+
sector: str | None = Field(None, description="Sector classification (Technology, Healthcare)")
|
|
135
133
|
|
|
136
134
|
# Market data
|
|
137
|
-
close_price:
|
|
138
|
-
close_price_as_of:
|
|
139
|
-
exchange:
|
|
135
|
+
close_price: Decimal | None = Field(None, ge=0, description="Latest closing price")
|
|
136
|
+
close_price_as_of: date | None = Field(None, description="Date of close_price")
|
|
137
|
+
exchange: str | None = Field(None, description="Exchange (NASDAQ, NYSE, etc.)")
|
|
140
138
|
currency: str = Field("USD", description="Currency code (USD, EUR, etc.)")
|
|
141
139
|
|
|
142
140
|
|
|
@@ -198,26 +196,26 @@ class Holding(BaseModel):
|
|
|
198
196
|
institution_value: Decimal = Field(
|
|
199
197
|
..., ge=0, description="Current market value (quantity × price)"
|
|
200
198
|
)
|
|
201
|
-
cost_basis:
|
|
199
|
+
cost_basis: Decimal | None = Field(
|
|
202
200
|
None, ge=0, description="Total cost basis (original purchase price)"
|
|
203
201
|
)
|
|
204
202
|
|
|
205
203
|
# Additional data
|
|
206
204
|
currency: str = Field("USD", description="Currency code")
|
|
207
|
-
unofficial_currency_code:
|
|
208
|
-
as_of_date:
|
|
205
|
+
unofficial_currency_code: str | None = Field(None, description="For crypto/alt currencies")
|
|
206
|
+
as_of_date: date | None = Field(None, description="Date of pricing data")
|
|
209
207
|
|
|
210
208
|
if TYPE_CHECKING:
|
|
211
209
|
|
|
212
210
|
@property
|
|
213
|
-
def unrealized_gain_loss(self) ->
|
|
211
|
+
def unrealized_gain_loss(self) -> Decimal | None:
|
|
214
212
|
"""Calculate unrealized gain/loss (current value - cost basis)."""
|
|
215
213
|
if self.cost_basis is None:
|
|
216
214
|
return None
|
|
217
215
|
return self.institution_value - self.cost_basis
|
|
218
216
|
|
|
219
217
|
@property
|
|
220
|
-
def unrealized_gain_loss_percent(self) ->
|
|
218
|
+
def unrealized_gain_loss_percent(self) -> Decimal | None:
|
|
221
219
|
"""Calculate unrealized gain/loss percentage."""
|
|
222
220
|
if self.cost_basis is None or self.cost_basis == 0:
|
|
223
221
|
return None
|
|
@@ -228,7 +226,7 @@ class Holding(BaseModel):
|
|
|
228
226
|
|
|
229
227
|
@computed_field
|
|
230
228
|
@property
|
|
231
|
-
def unrealized_gain_loss(self) ->
|
|
229
|
+
def unrealized_gain_loss(self) -> Decimal | None:
|
|
232
230
|
"""Calculate unrealized gain/loss (current value - cost basis)."""
|
|
233
231
|
if self.cost_basis is None:
|
|
234
232
|
return None
|
|
@@ -236,7 +234,7 @@ class Holding(BaseModel):
|
|
|
236
234
|
|
|
237
235
|
@computed_field
|
|
238
236
|
@property
|
|
239
|
-
def unrealized_gain_loss_percent(self) ->
|
|
237
|
+
def unrealized_gain_loss_percent(self) -> Decimal | None:
|
|
240
238
|
"""Calculate unrealized gain/loss percentage."""
|
|
241
239
|
if self.cost_basis is None or self.cost_basis == 0:
|
|
242
240
|
return None
|
|
@@ -304,17 +302,17 @@ class InvestmentTransaction(BaseModel):
|
|
|
304
302
|
transaction_type: TransactionType = Field(
|
|
305
303
|
..., alias="type", description="Transaction type (buy, sell, dividend)"
|
|
306
304
|
)
|
|
307
|
-
subtype:
|
|
305
|
+
subtype: str | None = Field(None, description="Provider-specific subtype")
|
|
308
306
|
|
|
309
307
|
# Amounts
|
|
310
308
|
quantity: Decimal = Field(..., description="Number of shares (0 for fees/dividends)")
|
|
311
309
|
amount: Decimal = Field(..., description="Transaction amount (negative for purchases)")
|
|
312
|
-
price:
|
|
313
|
-
fees:
|
|
310
|
+
price: Decimal | None = Field(None, ge=0, description="Price per share")
|
|
311
|
+
fees: Decimal | None = Field(None, ge=0, description="Transaction fees")
|
|
314
312
|
|
|
315
313
|
# Additional data
|
|
316
314
|
currency: str = Field("USD", description="Currency code")
|
|
317
|
-
unofficial_currency_code:
|
|
315
|
+
unofficial_currency_code: str | None = Field(None, description="For crypto/alt currencies")
|
|
318
316
|
|
|
319
317
|
|
|
320
318
|
class InvestmentAccount(BaseModel):
|
|
@@ -371,10 +369,10 @@ class InvestmentAccount(BaseModel):
|
|
|
371
369
|
account_id: str = Field(..., description="Account identifier")
|
|
372
370
|
name: str = Field(..., description="Account name (Fidelity 401k)")
|
|
373
371
|
type: str = Field(..., description="Account type (investment)")
|
|
374
|
-
subtype:
|
|
372
|
+
subtype: str | None = Field(None, description="Account subtype (401k, ira, brokerage)")
|
|
375
373
|
|
|
376
374
|
# Balances
|
|
377
|
-
balances: dict[str,
|
|
375
|
+
balances: dict[str, Decimal | None] = Field(
|
|
378
376
|
..., description="Current, available, and limit balances"
|
|
379
377
|
)
|
|
380
378
|
|
|
@@ -405,7 +403,7 @@ class InvestmentAccount(BaseModel):
|
|
|
405
403
|
return holdings_value - self.total_cost_basis
|
|
406
404
|
|
|
407
405
|
@property
|
|
408
|
-
def total_unrealized_gain_loss_percent(self) ->
|
|
406
|
+
def total_unrealized_gain_loss_percent(self) -> Decimal | None:
|
|
409
407
|
"""Calculate total unrealized P&L percentage."""
|
|
410
408
|
if self.total_cost_basis == 0:
|
|
411
409
|
return None
|
|
@@ -439,7 +437,7 @@ class InvestmentAccount(BaseModel):
|
|
|
439
437
|
|
|
440
438
|
@computed_field
|
|
441
439
|
@property
|
|
442
|
-
def total_unrealized_gain_loss_percent(self) ->
|
|
440
|
+
def total_unrealized_gain_loss_percent(self) -> Decimal | None:
|
|
443
441
|
"""Calculate total unrealized P&L percentage."""
|
|
444
442
|
if self.total_cost_basis == 0:
|
|
445
443
|
return None
|
|
@@ -236,10 +236,10 @@ class InvestmentProvider(ABC):
|
|
|
236
236
|
Standardized SecurityType enum value
|
|
237
237
|
|
|
238
238
|
Example mappings:
|
|
239
|
-
Plaid: "equity"
|
|
240
|
-
Plaid: "mutual fund"
|
|
241
|
-
SnapTrade: "cs"
|
|
242
|
-
SnapTrade: "etf"
|
|
239
|
+
Plaid: "equity" -> SecurityType.equity
|
|
240
|
+
Plaid: "mutual fund" -> SecurityType.mutual_fund
|
|
241
|
+
SnapTrade: "cs" -> SecurityType.equity (common stock)
|
|
242
|
+
SnapTrade: "etf" -> SecurityType.etf
|
|
243
243
|
|
|
244
244
|
Note:
|
|
245
245
|
Override in provider-specific implementations for custom mappings.
|
|
@@ -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
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
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
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
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"],
|
fin_infra/markets/__init__.py
CHANGED
|
@@ -33,8 +33,8 @@ def easy_market(
|
|
|
33
33
|
"""Create a market data provider with zero or minimal configuration.
|
|
34
34
|
|
|
35
35
|
Auto-detects provider based on environment variables:
|
|
36
|
-
1. If ALPHA_VANTAGE_API_KEY or ALPHAVANTAGE_API_KEY is set
|
|
37
|
-
2. Otherwise
|
|
36
|
+
1. If ALPHA_VANTAGE_API_KEY or ALPHAVANTAGE_API_KEY is set -> Alpha Vantage
|
|
37
|
+
2. Otherwise -> Yahoo Finance (no key needed)
|
|
38
38
|
|
|
39
39
|
Args:
|
|
40
40
|
provider: Provider name ("alphavantage" or "yahoo").
|
fin_infra/models/accounts.py
CHANGED
|
@@ -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:
|
|
28
|
+
mask: str | None = None
|
|
30
29
|
currency: str = "USD"
|
|
31
|
-
institution:
|
|
32
|
-
balance_available:
|
|
33
|
-
balance_current:
|
|
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
|
fin_infra/models/transactions.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import datetime
|
|
4
4
|
from decimal import Decimal
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, field_validator
|
|
@@ -15,7 +15,7 @@ class Transaction(BaseModel):
|
|
|
15
15
|
|
|
16
16
|
id: str
|
|
17
17
|
account_id: str
|
|
18
|
-
date: date
|
|
18
|
+
date: datetime.date
|
|
19
19
|
amount: Decimal
|
|
20
20
|
currency: str = "USD"
|
|
21
21
|
description: str | None = None
|
fin_infra/net_worth/__init__.py
CHANGED
|
@@ -5,15 +5,15 @@ Calculates net worth by aggregating balances from multiple financial providers
|
|
|
5
5
|
(banking, brokerage, crypto) with historical snapshots and change detection.
|
|
6
6
|
|
|
7
7
|
**Feature Status**:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
13
|
|
|
14
14
|
**Key Features**:
|
|
15
15
|
- Multi-provider aggregation (banking + brokerage + crypto)
|
|
16
|
-
- Currency normalization (all currencies
|
|
16
|
+
- Currency normalization (all currencies -> USD)
|
|
17
17
|
- Historical snapshots (daily at midnight UTC)
|
|
18
18
|
- Change detection (>5% or >$10k triggers webhook)
|
|
19
19
|
- Asset allocation breakdown (pie charts)
|
|
@@ -59,7 +59,7 @@ class NetWorthAggregator:
|
|
|
59
59
|
- Multi-provider support (banking, brokerage, crypto)
|
|
60
60
|
- Parallel account fetching (faster performance)
|
|
61
61
|
- Graceful error handling (continue if one provider fails)
|
|
62
|
-
- Currency normalization (all
|
|
62
|
+
- Currency normalization (all -> base currency)
|
|
63
63
|
- Market value calculation (stocks/crypto)
|
|
64
64
|
|
|
65
65
|
**Example**:
|
|
@@ -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
|
|
6
|
+
- Currency normalization (all currencies -> base currency)
|
|
7
7
|
- Asset allocation breakdown
|
|
8
8
|
- Change detection (amount + percentage)
|
|
9
9
|
|
fin_infra/net_worth/insights.py
CHANGED
|
@@ -167,7 +167,7 @@ Be specific with numbers. Cite percentage changes and dollar amounts.
|
|
|
167
167
|
Focus on actionable insights, not generic advice.
|
|
168
168
|
|
|
169
169
|
Example 1:
|
|
170
|
-
User: Net worth: $500k
|
|
170
|
+
User: Net worth: $500k -> $575k over 6 months. Assets: +$65k (investments +$60k, savings +$5k). Liabilities: -$10k (new mortgage).
|
|
171
171
|
Response: {
|
|
172
172
|
"summary": "Net worth increased 15% ($75k) over 6 months, driven primarily by strong investment performance.",
|
|
173
173
|
"period": "6 months",
|
|
@@ -191,7 +191,7 @@ Response: {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
Example 2:
|
|
194
|
-
User: Net worth: $100k
|
|
194
|
+
User: Net worth: $100k -> $95k over 3 months. Assets: -$2k (market down). Liabilities: +$3k (credit card debt).
|
|
195
195
|
Response: {
|
|
196
196
|
"summary": "Net worth decreased 5% ($5k) over 3 months due to market decline and rising credit card debt.",
|
|
197
197
|
"period": "3 months",
|
|
@@ -215,7 +215,7 @@ Response: {
|
|
|
215
215
|
"confidence": 0.89
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
219
219
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
220
220
|
|
|
221
221
|
DEBT_REDUCTION_SYSTEM_PROMPT = """You are a debt counselor using the avalanche method (highest APR first).
|
|
@@ -269,7 +269,7 @@ Response: {
|
|
|
269
269
|
"confidence": 0.98
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
|
|
272
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
273
273
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
274
274
|
|
|
275
275
|
GOAL_RECOMMENDATION_SYSTEM_PROMPT = """You are a financial planner validating goals and suggesting paths.
|
|
@@ -322,7 +322,7 @@ Response: {
|
|
|
322
322
|
"confidence": 0.89
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
|
|
325
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
326
326
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
327
327
|
|
|
328
328
|
ASSET_ALLOCATION_SYSTEM_PROMPT = """You are a portfolio advisor recommending asset allocation.
|
|
@@ -333,7 +333,7 @@ Given current allocation, age, and risk tolerance:
|
|
|
333
333
|
3. Provide specific rebalancing steps
|
|
334
334
|
|
|
335
335
|
Rule of thumb:
|
|
336
|
-
- Stock allocation = 100 - age (e.g., age 35
|
|
336
|
+
- Stock allocation = 100 - age (e.g., age 35 -> 65% stocks)
|
|
337
337
|
- Bonds for stability (increases with age)
|
|
338
338
|
- Cash for emergency fund (3-6 months expenses)
|
|
339
339
|
|
|
@@ -365,7 +365,7 @@ Response: {
|
|
|
365
365
|
"confidence": 0.91
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
|
|
368
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
369
369
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
370
370
|
|
|
371
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
|
|
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
|
|
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:
|
|
@@ -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:
|
|
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:
|
|
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}
|
|
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:
|
|
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}
|
|
111
|
+
logger.error(f"Failed to get rate {from_currency} -> {to_currency}: {e}")
|
|
113
112
|
raise CurrencyNotSupportedError(
|
|
114
|
-
f"Rate not available: {from_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:
|
|
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:
|
|
15
|
-
cusip:
|
|
16
|
-
isin:
|
|
17
|
-
sector:
|
|
18
|
-
industry:
|
|
19
|
-
market_cap:
|
|
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:
|
|
30
|
-
timestamp:
|
|
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:
|
|
40
|
+
date: DateType | None = Field(None, description="Rate date")
|
|
@@ -109,7 +109,7 @@ TICKER_TO_ISIN = {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
# Provider-specific symbol normalization
|
|
112
|
-
# Maps provider-specific format
|
|
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:
|
|
235
|
-
isin:
|
|
236
|
-
metadata:
|
|
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).
|