fin-infra 0.1.64__py3-none-any.whl → 0.1.66__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/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +1 -2
- fin_infra/analytics/spending.py +5 -5
- fin_infra/banking/__init__.py +1 -1
- fin_infra/banking/utils.py +5 -6
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/add.py +13 -15
- fin_infra/categorization/engine.py +3 -3
- fin_infra/categorization/llm_layer.py +2 -3
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/compliance/__init__.py +0 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +1 -1
- fin_infra/insights/__init__.py +7 -7
- fin_infra/investments/__init__.py +1 -1
- fin_infra/investments/add.py +2 -3
- fin_infra/investments/models.py +100 -46
- fin_infra/investments/providers/plaid.py +2 -3
- fin_infra/investments/providers/snaptrade.py +2 -2
- fin_infra/markets/__init__.py +0 -4
- fin_infra/models/transactions.py +1 -1
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/ease.py +34 -13
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +12 -12
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/base.py +100 -8
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/recurring/add.py +18 -9
- fin_infra/recurring/detectors_llm.py +8 -7
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +7 -6
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizers.py +7 -6
- {fin_infra-0.1.64.dist-info → fin_infra-0.1.66.dist-info}/METADATA +1 -2
- {fin_infra-0.1.64.dist-info → fin_infra-0.1.66.dist-info}/RECORD +42 -42
- {fin_infra-0.1.64.dist-info → fin_infra-0.1.66.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.64.dist-info → fin_infra-0.1.66.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.64.dist-info → fin_infra-0.1.66.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/cash_flow.py
CHANGED
|
@@ -6,6 +6,7 @@ Provides income vs expense analysis, breakdowns by source/category, and forecast
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
|
+
from decimal import Decimal
|
|
9
10
|
from typing import Any, Optional
|
|
10
11
|
|
|
11
12
|
from ..models import Transaction
|
|
@@ -219,7 +220,7 @@ async def forecast_cash_flow(
|
|
|
219
220
|
def _categorize_transactions(
|
|
220
221
|
transactions: list[Transaction],
|
|
221
222
|
categorization_provider=None,
|
|
222
|
-
) -> tuple[dict[str,
|
|
223
|
+
) -> tuple[dict[str, Decimal], dict[str, Decimal]]:
|
|
223
224
|
"""Helper to categorize transactions into income sources and expense categories.
|
|
224
225
|
|
|
225
226
|
Args:
|
|
@@ -229,19 +230,19 @@ def _categorize_transactions(
|
|
|
229
230
|
Returns:
|
|
230
231
|
Tuple of (income_by_source, expenses_by_category) dicts
|
|
231
232
|
"""
|
|
232
|
-
income_by_source: dict[str,
|
|
233
|
-
expenses_by_category: dict[str,
|
|
233
|
+
income_by_source: dict[str, Decimal] = {}
|
|
234
|
+
expenses_by_category: dict[str, Decimal] = {}
|
|
234
235
|
|
|
235
236
|
for txn in transactions:
|
|
236
237
|
if txn.amount > 0:
|
|
237
238
|
# Income transaction
|
|
238
239
|
source = _determine_income_source(txn)
|
|
239
|
-
income_by_source[source] = income_by_source.get(source, 0) + txn.amount
|
|
240
|
+
income_by_source[source] = income_by_source.get(source, Decimal(0)) + txn.amount
|
|
240
241
|
else:
|
|
241
242
|
# Expense transaction
|
|
242
243
|
category = _get_expense_category(txn, categorization_provider)
|
|
243
244
|
amount = abs(txn.amount)
|
|
244
|
-
expenses_by_category[category] = expenses_by_category.get(category, 0) + amount
|
|
245
|
+
expenses_by_category[category] = expenses_by_category.get(category, Decimal(0)) + amount
|
|
245
246
|
|
|
246
247
|
return income_by_source, expenses_by_category
|
|
247
248
|
|
fin_infra/analytics/portfolio.py
CHANGED
|
@@ -565,7 +565,6 @@ def portfolio_metrics_with_holdings(holdings: list) -> PortfolioMetrics:
|
|
|
565
565
|
>>> metrics = portfolio_metrics_with_holdings(holdings)
|
|
566
566
|
"""
|
|
567
567
|
# Import here to avoid circular dependency
|
|
568
|
-
from decimal import Decimal
|
|
569
568
|
|
|
570
569
|
# Calculate total portfolio value and cost basis
|
|
571
570
|
total_value = float(sum(
|
|
@@ -739,7 +738,7 @@ def _calculate_allocation_from_holdings(
|
|
|
739
738
|
}
|
|
740
739
|
|
|
741
740
|
# Sum values by asset class
|
|
742
|
-
allocation_values = defaultdict(float)
|
|
741
|
+
allocation_values: dict[str, float] = defaultdict(float)
|
|
743
742
|
for holding in holdings:
|
|
744
743
|
security_type = holding.security.type.value if hasattr(holding.security.type, 'value') else holding.security.type
|
|
745
744
|
asset_class = type_to_class.get(security_type, "Other")
|
fin_infra/analytics/spending.py
CHANGED
|
@@ -292,10 +292,10 @@ async def _calculate_spending_trends(
|
|
|
292
292
|
# In reality, would fetch historical data
|
|
293
293
|
previous_amount = current_amount * Decimal("0.9")
|
|
294
294
|
|
|
295
|
-
change_percent = (
|
|
296
|
-
((current_amount - previous_amount) / previous_amount) * 100
|
|
295
|
+
change_percent: float = (
|
|
296
|
+
float((current_amount - previous_amount) / previous_amount) * 100
|
|
297
297
|
if previous_amount > 0
|
|
298
|
-
else 0
|
|
298
|
+
else 0.0
|
|
299
299
|
)
|
|
300
300
|
|
|
301
301
|
# Threshold for "stable" is within 5%
|
|
@@ -343,8 +343,8 @@ async def _detect_spending_anomalies(
|
|
|
343
343
|
# In reality, would calculate from historical data
|
|
344
344
|
average_amount = current_amount * Decimal("0.8")
|
|
345
345
|
|
|
346
|
-
deviation_percent = (
|
|
347
|
-
((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0
|
|
346
|
+
deviation_percent: float = (
|
|
347
|
+
float((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0.0
|
|
348
348
|
)
|
|
349
349
|
|
|
350
350
|
# Detect anomalies based on deviation
|
fin_infra/banking/__init__.py
CHANGED
fin_infra/banking/utils.py
CHANGED
|
@@ -8,11 +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
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
12
|
from typing import Any, Dict, Optional, Literal
|
|
13
13
|
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
-
from pydantic.json_schema import JsonSchemaValue
|
|
15
|
-
from pydantic_core import core_schema
|
|
16
14
|
|
|
17
15
|
from ..providers.base import BankingProvider
|
|
18
16
|
|
|
@@ -270,7 +268,7 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
|
|
|
270
268
|
>>> safe_data = sanitize_connection_status(status)
|
|
271
269
|
>>> return {"connections": safe_data} # Safe to return to client
|
|
272
270
|
"""
|
|
273
|
-
result = {
|
|
271
|
+
result: dict[str, Any] = {
|
|
274
272
|
"has_any_connection": status.has_any_connection,
|
|
275
273
|
"connected_providers": status.connected_providers,
|
|
276
274
|
"primary_provider": status.primary_provider,
|
|
@@ -280,7 +278,8 @@ def sanitize_connection_status(status: BankingConnectionStatus) -> Dict[str, Any
|
|
|
280
278
|
for provider_name in ["plaid", "teller", "mx"]:
|
|
281
279
|
info = getattr(status, provider_name)
|
|
282
280
|
if info:
|
|
283
|
-
result["providers"]
|
|
281
|
+
providers_dict: dict[str, Any] = result["providers"]
|
|
282
|
+
providers_dict[provider_name] = {
|
|
284
283
|
"connected": info.connected,
|
|
285
284
|
"item_id": info.item_id,
|
|
286
285
|
"enrollment_id": info.enrollment_id,
|
|
@@ -415,7 +414,7 @@ async def test_connection_health(
|
|
|
415
414
|
"""
|
|
416
415
|
try:
|
|
417
416
|
# Try to fetch accounts (lightweight call)
|
|
418
|
-
|
|
417
|
+
provider.accounts(access_token)
|
|
419
418
|
|
|
420
419
|
# If we got here, connection is healthy
|
|
421
420
|
return True, None
|
fin_infra/cashflows/__init__.py
CHANGED
|
@@ -22,10 +22,13 @@ Example usage:
|
|
|
22
22
|
rate = irr(cashflows)
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
from typing import
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
26
|
|
|
27
27
|
import numpy_financial as npf
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
|
|
29
32
|
from .core import npv, irr
|
|
30
33
|
|
|
31
34
|
__all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
|
|
@@ -110,7 +113,7 @@ def pv(rate: float, nper: int, pmt: float, fv: float = 0, when: str = "end") ->
|
|
|
110
113
|
|
|
111
114
|
|
|
112
115
|
def add_cashflows(
|
|
113
|
-
app: "FastAPI",
|
|
116
|
+
app: "FastAPI",
|
|
114
117
|
*,
|
|
115
118
|
prefix: str = "/cashflows",
|
|
116
119
|
) -> None:
|
|
@@ -169,11 +172,6 @@ def add_cashflows(
|
|
|
169
172
|
- Integrated with svc-infra observability
|
|
170
173
|
- Scoped docs at {prefix}/docs
|
|
171
174
|
"""
|
|
172
|
-
from typing import TYPE_CHECKING
|
|
173
|
-
|
|
174
|
-
if TYPE_CHECKING:
|
|
175
|
-
from fastapi import FastAPI
|
|
176
|
-
|
|
177
175
|
from pydantic import BaseModel, Field
|
|
178
176
|
|
|
179
177
|
# Import svc-infra public router (no auth - utility calculations)
|
|
@@ -254,4 +252,4 @@ def add_cashflows(
|
|
|
254
252
|
# Mount router
|
|
255
253
|
app.include_router(router, include_in_schema=True)
|
|
256
254
|
|
|
257
|
-
print(
|
|
255
|
+
print("✅ Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
|
fin_infra/categorization/add.py
CHANGED
|
@@ -136,21 +136,19 @@ def add_categorization(
|
|
|
136
136
|
categories = get_all_categories()
|
|
137
137
|
|
|
138
138
|
# Return category metadata
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for cat in categories
|
|
153
|
-
]
|
|
139
|
+
result = []
|
|
140
|
+
for cat in categories:
|
|
141
|
+
meta = get_category_metadata(cat)
|
|
142
|
+
result.append(
|
|
143
|
+
{
|
|
144
|
+
"name": cat.value,
|
|
145
|
+
"group": meta.group.value if meta else None,
|
|
146
|
+
"display_name": meta.display_name if meta else cat.value,
|
|
147
|
+
"description": meta.description if meta else None,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return result
|
|
154
152
|
|
|
155
153
|
@router.get("/stats", response_model=CategoryStats)
|
|
156
154
|
async def get_stats():
|
|
@@ -95,7 +95,7 @@ class CategorizationEngine:
|
|
|
95
95
|
Returns:
|
|
96
96
|
CategoryPrediction with category, confidence, and method
|
|
97
97
|
"""
|
|
98
|
-
|
|
98
|
+
time.perf_counter()
|
|
99
99
|
|
|
100
100
|
# Normalize merchant name
|
|
101
101
|
normalized = self._normalize(merchant_name)
|
|
@@ -333,7 +333,7 @@ def get_engine() -> CategorizationEngine:
|
|
|
333
333
|
return _default_engine
|
|
334
334
|
|
|
335
335
|
|
|
336
|
-
def categorize(
|
|
336
|
+
async def categorize(
|
|
337
337
|
merchant_name: str,
|
|
338
338
|
user_id: Optional[str] = None,
|
|
339
339
|
include_alternatives: bool = False,
|
|
@@ -350,4 +350,4 @@ def categorize(
|
|
|
350
350
|
CategoryPrediction with category, confidence, and method
|
|
351
351
|
"""
|
|
352
352
|
engine = get_engine()
|
|
353
|
-
return engine.categorize(merchant_name, user_id, include_alternatives)
|
|
353
|
+
return await engine.categorize(merchant_name, user_id, include_alternatives)
|
|
@@ -15,13 +15,12 @@ Expected performance:
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import
|
|
18
|
+
from typing import Any, List, Optional, Tuple, cast
|
|
19
19
|
from pydantic import BaseModel, Field
|
|
20
20
|
|
|
21
21
|
# ai-infra imports
|
|
22
22
|
try:
|
|
23
23
|
from ai_infra.llm import LLM
|
|
24
|
-
from ai_infra.llm.providers import Providers
|
|
25
24
|
except ImportError:
|
|
26
25
|
raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
|
|
27
26
|
|
|
@@ -217,7 +216,7 @@ class LLMCategorizer:
|
|
|
217
216
|
user_message = self._build_user_message(merchant_name, user_id)
|
|
218
217
|
|
|
219
218
|
# Call LLM with retry logic
|
|
220
|
-
extra = {
|
|
219
|
+
extra: dict[str, Any] = {
|
|
221
220
|
"retry": {
|
|
222
221
|
"max_tries": 3,
|
|
223
222
|
"base": 0.5,
|
fin_infra/chat/__init__.py
CHANGED
|
@@ -28,6 +28,11 @@ Example:
|
|
|
28
28
|
)
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from fastapi import FastAPI
|
|
35
|
+
|
|
31
36
|
from fin_infra.chat.planning import (
|
|
32
37
|
FinancialPlanningConversation,
|
|
33
38
|
ConversationResponse,
|
|
@@ -51,7 +56,7 @@ __all__ = [
|
|
|
51
56
|
|
|
52
57
|
|
|
53
58
|
def add_financial_conversation(
|
|
54
|
-
app: "FastAPI",
|
|
59
|
+
app: "FastAPI",
|
|
55
60
|
*,
|
|
56
61
|
prefix: str = "/chat",
|
|
57
62
|
conversation: FinancialPlanningConversation | None = None,
|
|
@@ -110,11 +115,6 @@ def add_financial_conversation(
|
|
|
110
115
|
- Includes financial advice disclaimer in all responses
|
|
111
116
|
- Logs all LLM calls for compliance (via svc-infra logging)
|
|
112
117
|
"""
|
|
113
|
-
from typing import TYPE_CHECKING, Any
|
|
114
|
-
|
|
115
|
-
if TYPE_CHECKING:
|
|
116
|
-
from fastapi import FastAPI
|
|
117
|
-
|
|
118
118
|
from pydantic import BaseModel, Field
|
|
119
119
|
|
|
120
120
|
# Import svc-infra user router (requires auth)
|
|
@@ -149,15 +149,6 @@ def add_financial_conversation(
|
|
|
149
149
|
# TODO: Get user_id from svc-infra auth context
|
|
150
150
|
user_id = "demo_user" # Placeholder
|
|
151
151
|
|
|
152
|
-
# Check for sensitive content
|
|
153
|
-
if is_sensitive_question(request.question):
|
|
154
|
-
return ConversationResponse(
|
|
155
|
-
answer="I cannot process requests containing sensitive information like SSNs, passwords, or account numbers. Please rephrase your question without this information.",
|
|
156
|
-
follow_up_questions=[],
|
|
157
|
-
conversation_id=f"{user_id}_denied",
|
|
158
|
-
disclaimer="This is an automated safety response.",
|
|
159
|
-
)
|
|
160
|
-
|
|
161
152
|
# Ask conversation
|
|
162
153
|
response = await conversation.ask(
|
|
163
154
|
user_id=user_id,
|
|
@@ -173,7 +164,7 @@ def add_financial_conversation(
|
|
|
173
164
|
# TODO: Get user_id from svc-infra auth context
|
|
174
165
|
user_id = "demo_user"
|
|
175
166
|
context = await conversation._get_context(user_id)
|
|
176
|
-
return context.
|
|
167
|
+
return context.previous_exchanges if context else []
|
|
177
168
|
|
|
178
169
|
@router.delete("/history")
|
|
179
170
|
async def clear_history():
|
fin_infra/chat/planning.py
CHANGED
|
@@ -338,8 +338,65 @@ class FinancialPlanningConversation:
|
|
|
338
338
|
# Save updated context (24h TTL)
|
|
339
339
|
await self._save_context(context)
|
|
340
340
|
|
|
341
|
+
# Track latest session id for convenience endpoints (history/clear).
|
|
342
|
+
# Best-effort: failures here must not break the chat response.
|
|
343
|
+
try:
|
|
344
|
+
await self.cache.set(
|
|
345
|
+
self._latest_session_key(user_id),
|
|
346
|
+
context.session_id,
|
|
347
|
+
ttl=86400,
|
|
348
|
+
)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
341
352
|
return response
|
|
342
353
|
|
|
354
|
+
# ---------------------------------------------------------------------
|
|
355
|
+
# Backward-compatible context helpers
|
|
356
|
+
# ---------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def _latest_session_key(self, user_id: str) -> str:
|
|
359
|
+
return f"fin_infra:conversation_latest_session:{user_id}"
|
|
360
|
+
|
|
361
|
+
async def _get_latest_session_id(self, user_id: str) -> str | None:
|
|
362
|
+
try:
|
|
363
|
+
value = await self.cache.get(self._latest_session_key(user_id))
|
|
364
|
+
except Exception:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
if value is None:
|
|
368
|
+
return None
|
|
369
|
+
if isinstance(value, bytes):
|
|
370
|
+
try:
|
|
371
|
+
return value.decode("utf-8")
|
|
372
|
+
except Exception:
|
|
373
|
+
return None
|
|
374
|
+
if isinstance(value, str):
|
|
375
|
+
return value
|
|
376
|
+
return str(value)
|
|
377
|
+
|
|
378
|
+
async def _get_context(
|
|
379
|
+
self, user_id: str, session_id: str | None = None
|
|
380
|
+
) -> ConversationContext | None:
|
|
381
|
+
if session_id is None:
|
|
382
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
383
|
+
if session_id is None:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
return await self._load_context(user_id=user_id, session_id=session_id)
|
|
387
|
+
|
|
388
|
+
async def _clear_context(self, user_id: str, session_id: str | None = None) -> None:
|
|
389
|
+
if session_id is None:
|
|
390
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
391
|
+
|
|
392
|
+
if session_id is not None:
|
|
393
|
+
await self.clear_session(user_id=user_id, session_id=session_id)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
await self.cache.delete(self._latest_session_key(user_id))
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
|
|
343
400
|
async def _load_context(
|
|
344
401
|
self,
|
|
345
402
|
user_id: str,
|
fin_infra/compliance/__init__.py
CHANGED
|
@@ -118,7 +118,6 @@ def add_compliance_tracking(
|
|
|
118
118
|
|
|
119
119
|
# Track only GET requests (data access)
|
|
120
120
|
if method != "GET":
|
|
121
|
-
from starlette.responses import Response as StarletteResponse
|
|
122
121
|
return cast("Response", await call_next(request))
|
|
123
122
|
|
|
124
123
|
# Determine if path is a compliance-tracked endpoint
|
fin_infra/crypto/insights.py
CHANGED
|
@@ -258,10 +258,8 @@ Provide your insight:"""
|
|
|
258
258
|
|
|
259
259
|
try:
|
|
260
260
|
# Use natural language conversation (no output_schema)
|
|
261
|
-
# Note: In tests, achat is mocked with messages= parameter
|
|
262
|
-
# In production, this should use user_msg, provider, model_name parameters
|
|
263
261
|
response = await llm.achat(
|
|
264
|
-
|
|
262
|
+
user_msg=prompt,
|
|
265
263
|
)
|
|
266
264
|
|
|
267
265
|
# Parse response text
|
fin_infra/documents/add.py
CHANGED
|
@@ -104,7 +104,7 @@ def add_documents(
|
|
|
104
104
|
|
|
105
105
|
# Step 1: Mount base endpoints (upload, list, get, delete) via svc-infra
|
|
106
106
|
# This returns the base DocumentManager, but we'll create our own FinancialDocumentManager
|
|
107
|
-
if HAS_SVC_INFRA_DOCUMENTS and add_base_documents:
|
|
107
|
+
if HAS_SVC_INFRA_DOCUMENTS and add_base_documents is not None:
|
|
108
108
|
add_base_documents(app, storage_backend=storage, prefix=prefix, tags=tags)
|
|
109
109
|
else:
|
|
110
110
|
# Legacy mode: mount basic endpoints inline (for svc-infra < 0.1.668)
|
fin_infra/insights/__init__.py
CHANGED
|
@@ -10,6 +10,11 @@ Aggregates insights from multiple sources:
|
|
|
10
10
|
- Cash flow projections
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
|
|
13
18
|
from .models import Insight, InsightFeed, InsightPriority, InsightCategory
|
|
14
19
|
from .aggregator import aggregate_insights, get_user_insights
|
|
15
20
|
|
|
@@ -25,7 +30,7 @@ __all__ = [
|
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
def add_insights(
|
|
28
|
-
app: "FastAPI",
|
|
33
|
+
app: "FastAPI",
|
|
29
34
|
*,
|
|
30
35
|
prefix: str = "/insights",
|
|
31
36
|
) -> None:
|
|
@@ -71,11 +76,6 @@ def add_insights(
|
|
|
71
76
|
- Real-time aggregation from net worth, budgets, goals, etc.
|
|
72
77
|
- Notification system for critical insights
|
|
73
78
|
"""
|
|
74
|
-
from typing import TYPE_CHECKING
|
|
75
|
-
|
|
76
|
-
if TYPE_CHECKING:
|
|
77
|
-
from fastapi import FastAPI
|
|
78
|
-
|
|
79
79
|
from fastapi import Query
|
|
80
80
|
|
|
81
81
|
# Import svc-infra user router (requires auth)
|
|
@@ -125,5 +125,5 @@ def add_insights(
|
|
|
125
125
|
# Mount router
|
|
126
126
|
app.include_router(router, include_in_schema=True)
|
|
127
127
|
|
|
128
|
-
print(
|
|
128
|
+
print("✅ Insights feed enabled (unified financial insights)")
|
|
129
129
|
|
|
@@ -180,7 +180,7 @@ def add_investments(
|
|
|
180
180
|
# Resolve provider from string Literal to actual InvestmentProvider instance
|
|
181
181
|
resolved_provider: InvestmentProviderBase | None = None
|
|
182
182
|
if provider is not None:
|
|
183
|
-
resolved_provider = easy_investments(provider=provider, **provider_config)
|
|
183
|
+
resolved_provider = easy_investments(provider=provider, **provider_config)
|
|
184
184
|
|
|
185
185
|
return add_investments_impl(
|
|
186
186
|
app,
|
fin_infra/investments/add.py
CHANGED
|
@@ -7,14 +7,13 @@ transactions, accounts, allocation, and securities data.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from datetime import date
|
|
10
|
-
from typing import TYPE_CHECKING, Optional
|
|
10
|
+
from typing import TYPE_CHECKING, Optional
|
|
11
11
|
|
|
12
|
-
from fastapi import HTTPException
|
|
12
|
+
from fastapi import HTTPException
|
|
13
13
|
from pydantic import BaseModel, Field
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from fastapi import FastAPI
|
|
17
|
-
from svc_infra.api.fastapi.auth.security import Principal
|
|
18
17
|
|
|
19
18
|
# Import Identity for dependency injection
|
|
20
19
|
try:
|
fin_infra/investments/models.py
CHANGED
|
@@ -17,10 +17,10 @@ Models are provider-agnostic and normalize data from Plaid, SnapTrade, etc.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
-
from datetime import date
|
|
20
|
+
from datetime import date
|
|
21
21
|
from decimal import Decimal
|
|
22
22
|
from enum import Enum
|
|
23
|
-
from typing import Dict, List, Optional
|
|
23
|
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
24
24
|
|
|
25
25
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
26
26
|
|
|
@@ -201,22 +201,41 @@ class Holding(BaseModel):
|
|
|
201
201
|
unofficial_currency_code: Optional[str] = Field(None, description="For crypto/alt currencies")
|
|
202
202
|
as_of_date: Optional[date] = Field(None, description="Date of pricing data")
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
204
|
+
if TYPE_CHECKING:
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def unrealized_gain_loss(self) -> Optional[Decimal]:
|
|
208
|
+
"""Calculate unrealized gain/loss (current value - cost basis)."""
|
|
209
|
+
if self.cost_basis is None:
|
|
210
|
+
return None
|
|
211
|
+
return self.institution_value - self.cost_basis
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
|
|
215
|
+
"""Calculate unrealized gain/loss percentage."""
|
|
216
|
+
if self.cost_basis is None or self.cost_basis == 0:
|
|
217
|
+
return None
|
|
218
|
+
gain_loss = self.institution_value - self.cost_basis
|
|
219
|
+
return round((gain_loss / self.cost_basis) * 100, 2)
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
|
|
223
|
+
@computed_field
|
|
224
|
+
@property
|
|
225
|
+
def unrealized_gain_loss(self) -> Optional[Decimal]:
|
|
226
|
+
"""Calculate unrealized gain/loss (current value - cost basis)."""
|
|
227
|
+
if self.cost_basis is None:
|
|
228
|
+
return None
|
|
229
|
+
return self.institution_value - self.cost_basis
|
|
230
|
+
|
|
231
|
+
@computed_field
|
|
232
|
+
@property
|
|
233
|
+
def unrealized_gain_loss_percent(self) -> Optional[Decimal]:
|
|
234
|
+
"""Calculate unrealized gain/loss percentage."""
|
|
235
|
+
if self.cost_basis is None or self.cost_basis == 0:
|
|
236
|
+
return None
|
|
237
|
+
gain_loss = self.institution_value - self.cost_basis
|
|
238
|
+
return round((gain_loss / self.cost_basis) * 100, 2)
|
|
220
239
|
|
|
221
240
|
|
|
222
241
|
class InvestmentTransaction(BaseModel):
|
|
@@ -350,34 +369,69 @@ class InvestmentAccount(BaseModel):
|
|
|
350
369
|
# Holdings
|
|
351
370
|
holdings: List[Holding] = Field(default_factory=list, description="List of holdings in account")
|
|
352
371
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
372
|
+
if TYPE_CHECKING:
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def total_value(self) -> Decimal:
|
|
376
|
+
"""Calculate total account value (sum of holdings + cash)."""
|
|
377
|
+
holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
|
|
378
|
+
cash_balance = self.balances.get("current") or Decimal(0)
|
|
379
|
+
return holdings_value + cash_balance
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def total_cost_basis(self) -> Decimal:
|
|
383
|
+
"""Calculate total cost basis (sum of cost_basis across holdings)."""
|
|
384
|
+
return sum(
|
|
385
|
+
(h.cost_basis for h in self.holdings if h.cost_basis is not None),
|
|
386
|
+
start=Decimal(0),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def total_unrealized_gain_loss(self) -> Decimal:
|
|
391
|
+
"""Calculate total unrealized P&L (value - cost_basis)."""
|
|
392
|
+
holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
|
|
393
|
+
return holdings_value - self.total_cost_basis
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
|
|
397
|
+
"""Calculate total unrealized P&L percentage."""
|
|
398
|
+
if self.total_cost_basis == 0:
|
|
399
|
+
return None
|
|
400
|
+
return round((self.total_unrealized_gain_loss / self.total_cost_basis) * 100, 2)
|
|
401
|
+
|
|
402
|
+
else:
|
|
403
|
+
|
|
404
|
+
@computed_field
|
|
405
|
+
@property
|
|
406
|
+
def total_value(self) -> Decimal:
|
|
407
|
+
"""Calculate total account value (sum of holdings + cash)."""
|
|
408
|
+
holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
|
|
409
|
+
cash_balance = self.balances.get("current") or Decimal(0)
|
|
410
|
+
return holdings_value + cash_balance
|
|
411
|
+
|
|
412
|
+
@computed_field
|
|
413
|
+
@property
|
|
414
|
+
def total_cost_basis(self) -> Decimal:
|
|
415
|
+
"""Calculate total cost basis (sum of cost_basis across holdings)."""
|
|
416
|
+
return sum(
|
|
417
|
+
(h.cost_basis for h in self.holdings if h.cost_basis is not None),
|
|
418
|
+
start=Decimal(0),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
@computed_field
|
|
422
|
+
@property
|
|
423
|
+
def total_unrealized_gain_loss(self) -> Decimal:
|
|
424
|
+
"""Calculate total unrealized P&L (value - cost_basis)."""
|
|
425
|
+
holdings_value = sum((h.institution_value for h in self.holdings), start=Decimal(0))
|
|
426
|
+
return holdings_value - self.total_cost_basis
|
|
427
|
+
|
|
428
|
+
@computed_field
|
|
429
|
+
@property
|
|
430
|
+
def total_unrealized_gain_loss_percent(self) -> Optional[Decimal]:
|
|
431
|
+
"""Calculate total unrealized P&L percentage."""
|
|
432
|
+
if self.total_cost_basis == 0:
|
|
433
|
+
return None
|
|
434
|
+
return round((self.total_unrealized_gain_loss / self.total_cost_basis) * 100, 2)
|
|
381
435
|
|
|
382
436
|
|
|
383
437
|
class AssetAllocation(BaseModel):
|