fin-infra 0.6.0__py3-none-any.whl → 0.8.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 +24 -0
- fin_infra/analytics/benchmark.py +594 -0
- fin_infra/analytics/ease.py +33 -2
- fin_infra/analytics/models.py +3 -0
- fin_infra/analytics/portfolio.py +113 -23
- fin_infra/analytics/rebalancing.py +50 -4
- fin_infra/analytics/rebalancing_llm.py +710 -0
- fin_infra/banking/__init__.py +4 -0
- fin_infra/categorization/llm_layer.py +1 -1
- fin_infra/insights/__init__.py +2 -1
- fin_infra/insights/aggregator.py +106 -45
- fin_infra/models/brokerage.py +1 -0
- fin_infra/providers/banking/teller_client.py +43 -8
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/METADATA +7 -1
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/RECORD +18 -16
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.8.0.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/portfolio.py
CHANGED
|
@@ -133,22 +133,28 @@ async def compare_to_benchmark(
|
|
|
133
133
|
accounts: list[str] | None = None,
|
|
134
134
|
brokerage_provider=None,
|
|
135
135
|
market_provider=None,
|
|
136
|
+
portfolio_history: list[tuple] | None = None,
|
|
136
137
|
) -> BenchmarkComparison:
|
|
137
138
|
"""Compare portfolio performance to benchmark index.
|
|
138
139
|
|
|
139
140
|
Calculates relative performance metrics including alpha (excess return),
|
|
140
141
|
beta (volatility relative to benchmark), and Sharpe ratio (risk-adjusted return).
|
|
141
142
|
|
|
143
|
+
Now uses REAL market data from fin-infra's market data providers (easy_market).
|
|
144
|
+
|
|
142
145
|
Args:
|
|
143
146
|
user_id: User identifier
|
|
144
147
|
benchmark: Benchmark ticker symbol (default: SPY for S&P 500)
|
|
145
|
-
period: Time period for comparison (1y,
|
|
148
|
+
period: Time period for comparison (1m, 3m, 6m, 1y, 2y, 5y, ytd, all)
|
|
146
149
|
accounts: Optional list of account IDs to include
|
|
147
150
|
brokerage_provider: Optional brokerage provider instance
|
|
148
151
|
market_provider: Optional market data provider instance
|
|
152
|
+
portfolio_history: Optional list of (date, value) tuples for portfolio history.
|
|
153
|
+
If not provided, will attempt to fetch from brokerage_provider
|
|
154
|
+
or fall back to mock data.
|
|
149
155
|
|
|
150
156
|
Returns:
|
|
151
|
-
BenchmarkComparison with alpha, beta, and performance metrics
|
|
157
|
+
BenchmarkComparison with alpha, beta, Sharpe ratio, and performance metrics
|
|
152
158
|
|
|
153
159
|
Supported Benchmarks:
|
|
154
160
|
- SPY: S&P 500
|
|
@@ -156,6 +162,7 @@ async def compare_to_benchmark(
|
|
|
156
162
|
- VTI: Total US Stock Market
|
|
157
163
|
- AGG: Total Bond Market
|
|
158
164
|
- VT: Total World Stock
|
|
165
|
+
- BND: Total Bond Market (Vanguard)
|
|
159
166
|
- Custom: Any valid ticker symbol
|
|
160
167
|
|
|
161
168
|
Performance Metrics:
|
|
@@ -164,55 +171,138 @@ async def compare_to_benchmark(
|
|
|
164
171
|
- Sharpe Ratio: (Return - Risk-free rate) / Standard deviation
|
|
165
172
|
|
|
166
173
|
Examples:
|
|
167
|
-
>>> # Compare to S&P 500 over 1 year
|
|
174
|
+
>>> # Compare to S&P 500 over 1 year (real benchmark data!)
|
|
168
175
|
>>> comp = await compare_to_benchmark("user123", benchmark="SPY", period="1y")
|
|
169
176
|
>>> print(f"Alpha: {comp.alpha:.2f}%")
|
|
177
|
+
>>> print(f"Benchmark (SPY) return: {comp.benchmark_return_percent:.2f}%")
|
|
170
178
|
|
|
171
|
-
>>> # Compare
|
|
172
|
-
>>>
|
|
179
|
+
>>> # Compare with custom portfolio history
|
|
180
|
+
>>> from datetime import date
|
|
181
|
+
>>> history = [(date(2024, 1, 1), 100000), (date(2024, 12, 31), 115000)]
|
|
182
|
+
>>> comp = await compare_to_benchmark(
|
|
183
|
+
... "user123",
|
|
184
|
+
... benchmark="QQQ",
|
|
185
|
+
... period="1y",
|
|
186
|
+
... portfolio_history=history,
|
|
187
|
+
... )
|
|
173
188
|
|
|
174
|
-
>>> #
|
|
189
|
+
>>> # Using a market provider (caching, etc.)
|
|
190
|
+
>>> from fin_infra.markets import easy_market
|
|
191
|
+
>>> market = easy_market()
|
|
175
192
|
>>> comp = await compare_to_benchmark(
|
|
176
193
|
... "user123",
|
|
177
194
|
... benchmark="VTI",
|
|
178
|
-
...
|
|
179
|
-
... accounts=["taxable_brokerage"]
|
|
195
|
+
... market_provider=market,
|
|
180
196
|
... )
|
|
181
197
|
"""
|
|
182
|
-
|
|
198
|
+
import logging
|
|
199
|
+
|
|
200
|
+
from .benchmark import get_benchmark_history
|
|
201
|
+
|
|
202
|
+
logger = logging.getLogger(__name__)
|
|
203
|
+
|
|
204
|
+
# Parse period to days for portfolio return calculation
|
|
183
205
|
period_days = _parse_benchmark_period(period)
|
|
184
206
|
|
|
185
207
|
# Get portfolio return for period
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
if portfolio_history:
|
|
209
|
+
# Use provided portfolio history
|
|
210
|
+
first_value = portfolio_history[0][1]
|
|
211
|
+
last_value = portfolio_history[-1][1]
|
|
212
|
+
portfolio_return_dollars = last_value - first_value
|
|
213
|
+
portfolio_return_percent = (
|
|
214
|
+
(portfolio_return_dollars / first_value * 100) if first_value > 0 else 0.0
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
# Fall back to mock calculation (for now - integrate with brokerage_provider in future)
|
|
218
|
+
portfolio_return_dollars, portfolio_return_percent = _calculate_portfolio_return(
|
|
219
|
+
user_id, period_days, accounts
|
|
220
|
+
)
|
|
190
221
|
|
|
191
|
-
# Get benchmark return
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
222
|
+
# Get REAL benchmark return from market data provider
|
|
223
|
+
try:
|
|
224
|
+
logger.info(f"[Portfolio] Fetching real benchmark data for {benchmark} period={period}")
|
|
225
|
+
benchmark_history = await get_benchmark_history(
|
|
226
|
+
benchmark,
|
|
227
|
+
period=period,
|
|
228
|
+
market_provider=market_provider,
|
|
229
|
+
)
|
|
230
|
+
benchmark_return_dollars = benchmark_history.end_price - benchmark_history.start_price
|
|
231
|
+
benchmark_return_percent = benchmark_history.total_return_percent
|
|
232
|
+
start_date = benchmark_history.start_date
|
|
233
|
+
end_date = benchmark_history.end_date
|
|
234
|
+
|
|
235
|
+
logger.info(
|
|
236
|
+
f"[Portfolio] Real benchmark data: {benchmark}={benchmark_return_percent:.2f}% "
|
|
237
|
+
f"({start_date} to {end_date})"
|
|
238
|
+
)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
# Fall back to mock data on error
|
|
241
|
+
logger.warning(f"[Portfolio] Failed to fetch real benchmark data, using mock: {e}")
|
|
242
|
+
benchmark_return_dollars, benchmark_return_percent = _get_benchmark_return(
|
|
243
|
+
benchmark, period_days
|
|
244
|
+
)
|
|
245
|
+
start_date = None
|
|
246
|
+
end_date = None
|
|
196
247
|
|
|
197
248
|
# Calculate alpha (excess return)
|
|
198
249
|
alpha = portfolio_return_percent - benchmark_return_percent
|
|
199
250
|
|
|
200
251
|
# Calculate beta (volatility relative to benchmark)
|
|
201
|
-
# TODO: Implement real beta calculation with historical returns
|
|
202
252
|
beta = _calculate_beta(user_id, benchmark, period_days)
|
|
203
253
|
|
|
254
|
+
# Calculate Sharpe ratio (simplified)
|
|
255
|
+
sharpe_ratio = _calculate_sharpe_ratio(portfolio_return_percent, period_days)
|
|
256
|
+
|
|
204
257
|
return BenchmarkComparison(
|
|
205
258
|
portfolio_return=portfolio_return_dollars,
|
|
206
|
-
portfolio_return_percent=portfolio_return_percent,
|
|
259
|
+
portfolio_return_percent=round(portfolio_return_percent, 2),
|
|
207
260
|
benchmark_return=benchmark_return_dollars,
|
|
208
|
-
benchmark_return_percent=benchmark_return_percent,
|
|
261
|
+
benchmark_return_percent=round(benchmark_return_percent, 2),
|
|
209
262
|
benchmark_symbol=benchmark,
|
|
210
|
-
alpha=alpha,
|
|
211
|
-
beta=beta,
|
|
263
|
+
alpha=round(alpha, 2),
|
|
264
|
+
beta=round(beta, 2) if beta is not None else None,
|
|
265
|
+
sharpe_ratio=round(sharpe_ratio, 2) if sharpe_ratio is not None else None,
|
|
212
266
|
period=period,
|
|
267
|
+
start_date=start_date,
|
|
268
|
+
end_date=end_date,
|
|
213
269
|
)
|
|
214
270
|
|
|
215
271
|
|
|
272
|
+
def _calculate_sharpe_ratio(
|
|
273
|
+
return_percent: float,
|
|
274
|
+
period_days: int,
|
|
275
|
+
risk_free_rate: float = 0.03,
|
|
276
|
+
) -> float | None:
|
|
277
|
+
"""Calculate simplified Sharpe ratio.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
return_percent: Portfolio return percentage for period
|
|
281
|
+
period_days: Number of days in period
|
|
282
|
+
risk_free_rate: Annual risk-free rate (default: 3%)
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Sharpe ratio or None if cannot calculate
|
|
286
|
+
"""
|
|
287
|
+
if period_days < 30:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# Annualize return
|
|
291
|
+
if period_days < 365:
|
|
292
|
+
annualized_return = return_percent * (365 / period_days)
|
|
293
|
+
else:
|
|
294
|
+
years = period_days / 365
|
|
295
|
+
annualized_return = ((1 + return_percent / 100) ** (1 / years) - 1) * 100
|
|
296
|
+
|
|
297
|
+
# Excess return over risk-free rate
|
|
298
|
+
excess_return = annualized_return - (risk_free_rate * 100)
|
|
299
|
+
|
|
300
|
+
# Estimate volatility (15% for diversified portfolio - simplified)
|
|
301
|
+
estimated_volatility = 15.0
|
|
302
|
+
|
|
303
|
+
return excess_return / estimated_volatility if estimated_volatility > 0 else None
|
|
304
|
+
|
|
305
|
+
|
|
216
306
|
# ============================================================================
|
|
217
307
|
# Helper Functions
|
|
218
308
|
# ============================================================================
|
|
@@ -110,12 +110,17 @@ def generate_rebalancing_plan(
|
|
|
110
110
|
)
|
|
111
111
|
|
|
112
112
|
# Map symbols to asset classes (simplified mapping)
|
|
113
|
-
|
|
113
|
+
symbol_class_map = _get_asset_class_mapping()
|
|
114
114
|
|
|
115
|
-
# Calculate current allocation
|
|
115
|
+
# Calculate current allocation using position's asset_class or fallback to symbol map
|
|
116
116
|
current_allocation: dict[str, Decimal] = {}
|
|
117
117
|
for position in positions:
|
|
118
|
-
asset_class
|
|
118
|
+
# Use position's asset_class field if available, otherwise fall back to symbol map
|
|
119
|
+
raw_asset_class = getattr(position, "asset_class", None) or symbol_class_map.get(
|
|
120
|
+
position.symbol, "other"
|
|
121
|
+
)
|
|
122
|
+
# Normalize asset class to match target allocation keys
|
|
123
|
+
asset_class = _normalize_asset_class(raw_asset_class)
|
|
119
124
|
position_value = Decimal(str(position.market_value))
|
|
120
125
|
current_allocation[asset_class] = (
|
|
121
126
|
current_allocation.get(asset_class, Decimal("0")) + position_value
|
|
@@ -143,7 +148,11 @@ def generate_rebalancing_plan(
|
|
|
143
148
|
)
|
|
144
149
|
|
|
145
150
|
for position in sorted_positions:
|
|
146
|
-
asset_class
|
|
151
|
+
# Use position's asset_class field if available, otherwise fall back to symbol map
|
|
152
|
+
raw_asset_class = getattr(position, "asset_class", None) or symbol_class_map.get(
|
|
153
|
+
position.symbol, "other"
|
|
154
|
+
)
|
|
155
|
+
asset_class = _normalize_asset_class(raw_asset_class)
|
|
147
156
|
if asset_class not in target_values:
|
|
148
157
|
continue
|
|
149
158
|
|
|
@@ -244,6 +253,43 @@ def generate_rebalancing_plan(
|
|
|
244
253
|
)
|
|
245
254
|
|
|
246
255
|
|
|
256
|
+
def _normalize_asset_class(raw_asset_class: str | None) -> str:
|
|
257
|
+
"""
|
|
258
|
+
Normalize asset class strings to standard rebalancing categories.
|
|
259
|
+
|
|
260
|
+
Maps detailed asset classes (e.g., 'us_equity', 'fixed_income') to
|
|
261
|
+
simplified categories that match target allocation keys ('stocks', 'bonds').
|
|
262
|
+
"""
|
|
263
|
+
if not raw_asset_class:
|
|
264
|
+
return "other"
|
|
265
|
+
|
|
266
|
+
raw = raw_asset_class.lower()
|
|
267
|
+
|
|
268
|
+
# Map to stocks
|
|
269
|
+
if raw in ["stocks", "us_equity", "equity", "stock", "international"]:
|
|
270
|
+
return "stocks"
|
|
271
|
+
|
|
272
|
+
# Map to bonds
|
|
273
|
+
if raw in ["bonds", "fixed_income", "bond"]:
|
|
274
|
+
return "bonds"
|
|
275
|
+
|
|
276
|
+
# Map to cash
|
|
277
|
+
if raw in ["cash", "money_market", "currency"]:
|
|
278
|
+
return "cash"
|
|
279
|
+
|
|
280
|
+
# Map to other specific categories
|
|
281
|
+
if raw in ["crypto", "cryptocurrency"]:
|
|
282
|
+
return "crypto"
|
|
283
|
+
|
|
284
|
+
if raw in ["realestate", "real_estate", "reit"]:
|
|
285
|
+
return "realestate"
|
|
286
|
+
|
|
287
|
+
if raw in ["commodities", "commodity"]:
|
|
288
|
+
return "commodities"
|
|
289
|
+
|
|
290
|
+
return raw # Return as-is for unknown categories
|
|
291
|
+
|
|
292
|
+
|
|
247
293
|
def _get_asset_class_mapping() -> dict[str, str]:
|
|
248
294
|
"""Map ticker symbols to asset classes."""
|
|
249
295
|
return {
|