fin-infra 0.6.0__py3-none-any.whl → 0.7.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.
@@ -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, 3y, 5y, ytd, max)
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 to Nasdaq 100 YTD
172
- >>> comp = await compare_to_benchmark("user123", benchmark="QQQ", period="ytd")
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
- >>> # Custom benchmark with specific accounts
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
- ... period="3y",
179
- ... accounts=["taxable_brokerage"]
195
+ ... market_provider=market,
180
196
  ... )
181
197
  """
182
- # Parse period to days
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
- # TODO: Integrate with real brokerage provider
187
- portfolio_return_dollars, portfolio_return_percent = _calculate_portfolio_return(
188
- user_id, period_days, accounts
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 for period
192
- # TODO: Integrate with real market data provider
193
- benchmark_return_dollars, benchmark_return_percent = _get_benchmark_return(
194
- benchmark, period_days
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
- asset_class_map = _get_asset_class_mapping()
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 = asset_class_map.get(position.symbol, "other")
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 = asset_class_map.get(position.symbol, "other")
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 {